read-commands.ts
756 lines · browse/src/read-commands.ts at 3f79d81
1/**
2 * Read commands — extract data from pages without side effects
3 *
4 * text, html, links, forms, accessibility, js, eval, css, attrs,
5 * console, network, cookies, storage, perf
6 */
8import type { TabSession } from './tab-session';
9import type { BrowserManager } from './browser-manager';
10import { consoleBuffer, networkBuffer, dialogBuffer } from './buffers';
11import type { Page, Frame } from 'playwright';
12import * as fs from 'fs';
13import * as path from 'path';
14import { TEMP_DIR } from './platform';
15import { inspectElement, formatInspectorResult, getModificationHistory } from './cdp-inspector';
16import { validateReadPath, validateOutputPath } from './path-security';
17import { stripLoneSurrogates } from './sanitize';
18// Re-export for backward compatibility (tests import from read-commands)
19export { validateReadPath } from './path-security';
21// Redaction patterns for sensitive cookie/storage values — exported for test coverage
22export const SENSITIVE_COOKIE_NAME = /(^|[_.-])(token|secret|key|password|credential|auth|jwt|session|csrf|sid)($|[_.-])|api.?key/i;
23export const SENSITIVE_COOKIE_VALUE = /^(eyJ|sk-|sk_live_|sk_test_|pk_live_|pk_test_|rk_live_|sk-ant-|ghp_|gho_|github_pat_|xox[bpsa]-|AKIA[A-Z0-9]{16}|AIza|SG\.|Bearer\s|sbp_)/;
25/** Detect await keyword, ignoring comments. Accepted risk: await in string literals triggers wrapping (harmless). */
26export function hasAwait(code: string): boolean {
27 const stripped = code.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '');
28 return /\bawait\b/.test(stripped);
29}
31/**
32 * Find the index of the bracket that closes the group opened at `start`
33 * (which must be `(` or `[`), skipping string literals and escapes.
34 * Returns -1 when unbalanced. Shared by the single-expression scanner below.
35 */
36function findBalancedClose(src: string, start: number): number {
37 const open = src[start];
38 const close = open === '(' ? ')' : ']';
39 let depth = 0;
40 let inString: string | null = null;
41 let escape = false;
42 for (let i = start; i < src.length; i++) {
43 const char = src[i];
44 if (escape) {
45 escape = false;
46 continue;
47 }
48 if (char === '\\' && inString) {
49 escape = true;
50 continue;
51 }
52 if (inString) {
53 if (char === inString) inString = null;
54 continue;
55 }
56 if (char === '"' || char === "'" || char === '`') {
57 inString = char;
58 continue;
59 }
60 if (char === open) {
61 depth++;
62 } else if (char === close) {
63 depth--;
64 if (depth === 0) return i;
65 }
66 }
67 return -1;
68}
70/**
71 * Detect whether code is a single top-level expression (such as an IIFE or parenthesized expression),
72 * even if it contains internal statements, semicolons, or multiple lines (#2727).
73 *
74 * The tail after the initial `(...)` group must be a CONTINUOUS member/call/
75 * index chain consumed to end-of-input. Accepting any tail that merely starts
76 * with `(` or `.` classified `(iife)().then(x=>x); stmt` as a single
77 * expression, and the expression wrapper then emitted a SyntaxError.
78 */
79export function isSingleParenOrIifeExpression(code: string): boolean {
80 const trimmed = code.trim().replace(/;+\s*$/, '');
81 let src = trimmed;
82 if (src.startsWith('await ') || src.startsWith('await\t') || src.startsWith('await\n')) {
83 src = src.slice(5).trim();
84 }
85 if (!src.startsWith('(')) return false;
87 const mainCloseIndex = findBalancedClose(src, 0);
88 if (mainCloseIndex === -1) return false;
90 // Consume the ENTIRE tail as a chain of `.member`, `(...)`, `[...]`, or
91 // optional-chaining segments. Anything else (a `;`, a second statement,
92 // an operator) means this is not a single expression.
93 let i = mainCloseIndex + 1;
94 while (i < src.length) {
95 const ch = src[i];
96 if (ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r') {
97 i++;
98 continue;
99 }
100 if (ch === '(' || ch === '[') {
101 const close = findBalancedClose(src, i);
102 if (close === -1) return false;
103 i = close + 1;
104 continue;
105 }
106 if (ch === '.' || (ch === '?' && src[i + 1] === '.')) {
107 i += ch === '.' ? 1 : 2;
108 // Member name (or the `(`/`[` of `?.()` / `?.[]`, handled next loop).
109 while (i < src.length && /[\w$]/.test(src[i])) i++;
110 continue;
111 }
112 return false;
113 }
114 return true;
115}
117/** Detect whether code needs a block wrapper {…} vs expression wrapper (…) inside an async IIFE. */
118export function needsBlockWrapper(code: string): boolean {
119 const trimmed = code.trim();
120 if (isSingleParenOrIifeExpression(trimmed)) return false;
121 const clean = trimmed.replace(/;+\s*$/, '');
122 if (clean.split('\n').length > 1) return true;
123 if (/\b(const|let|var|function|class|return|throw|if|for|while|switch|try)\b/.test(clean)) return true;
124 if (clean.includes(';')) return true;
125 return false;
126}
128/** Wrap code for page.evaluate(), using async IIFE with block or expression body as needed. */
129export function wrapForEvaluate(code: string): string {
130 if (!hasAwait(code)) return code;
131 const trimmed = code.trim();
132 const cleanExpr = trimmed.replace(/;+\s*$/, '');
133 return needsBlockWrapper(trimmed)
134 ? `(async()=>{\n${code}\n})()`
135 : `(async()=>(${cleanExpr}))()`;
136}
138/** Flags split out of `js`/`eval` args by parseOutArgs. */
139export interface OutArgs {
140 outPath?: string;
141 raw: boolean;
142 rest: string[];
143}
145/**
146 * Parse `--out <path>` / `--out=<path>` and `--raw` / `--raw=true|false` out of an
147 * arg list, returning the flags plus the remaining positional args (`rest`).
148 *
149 * Single source of truth shared by the js/eval handlers and the write-capability
150 * gate in server.ts, so the two never disagree on what counts as an `--out`
151 * invocation. Throws on malformed usage (repeated `--out`, missing value, bad
152 * `--raw` value) so the user gets a clear error instead of a silent misparse.
153 */
154export function parseOutArgs(args: string[]): OutArgs {
155 let outPath: string | undefined;
156 let raw = false;
157 const rest: string[] = [];
158 for (let i = 0; i < args.length; i++) {
159 const a = args[i];
160 if (a === '--out') {
161 if (outPath !== undefined) throw new Error('--out specified more than once');
162 const val = args[i + 1];
163 if (val === undefined || val.startsWith('--')) throw new Error('--out requires a file path');
164 outPath = val;
165 i++;
166 } else if (a.startsWith('--out=')) {
167 if (outPath !== undefined) throw new Error('--out specified more than once');
168 const val = a.slice('--out='.length);
169 if (val === '') throw new Error('--out requires a file path');
170 outPath = val;
171 } else if (a === '--raw') {
172 raw = true;
173 } else if (a.startsWith('--raw=')) {
174 const v = a.slice('--raw='.length).toLowerCase();
175 if (v !== 'true' && v !== 'false') throw new Error('--raw must be true or false');
176 raw = v === 'true';
177 } else {
178 rest.push(a);
179 }
180 }
181 return { outPath, raw, rest };
182}
184/**
185 * True iff an arg list contains an `--out` flag in any accepted form
186 * (`--out <path>` or `--out=<path>`). Used by the write-capability gate to
187 * decide whether an otherwise-read command (`js`/`eval`) is actually a write
188 * invocation. Mirrors parseOutArgs's `--out` recognition exactly. Never throws —
189 * a malformed `--out=` still counts as an out attempt (fail safe: gate it).
190 */
191export function hasOutArg(args: string[]): boolean {
192 return args.some(a => a === '--out' || a.startsWith('--out='));
193}
195/**
196 * Convert an evaluate() result to its string form — the exact conversion `js`/`eval`
197 * used inline before `--out` existed. Kept byte-for-byte: `typeof === 'object'`
198 * (which includes `null`) goes through JSON.stringify (so `null` → `"null"`);
199 * everything else via `String(result ?? '')` (so `undefined` → `''`). JSON.stringify
200 * still throws on circular / BigInt-bearing results, same as before.
201 */
202export function resultToString(result: unknown): string {
203 return typeof result === 'object'
204 ? JSON.stringify(result, null, 2)
205 : String(result ?? '');
206}
208/**
209 * Write an evaluate result string to disk for `--out`, returning bytes written.
210 *
211 * When the result is a base64 data URL (`data:<type>;...;base64,<payload>`) and
212 * `raw` is false, decode the payload to raw bytes — this is the Excalidraw / og-image
213 * path where a render function returns a PNG data URL. The header is parsed
214 * case-insensitively and split on the FIRST comma (data URLs can contain commas in
215 * the payload). The payload is validated against the base64 charset before decoding,
216 * because `Buffer.from(_, 'base64')` silently drops invalid characters and would
217 * otherwise write corrupted bytes. `--raw` forces a literal write even for data URLs.
218 *
219 * Non-base64 strings are surrogate-sanitized (matching what the stdout egress path
220 * did before) and written as UTF-8. Parent directories are created — validateOutputPath
221 * gates the location but does not mkdir.
222 */
223export function writeEvalResult(outPath: string, str: string, opts: { raw: boolean }): number {
224 validateOutputPath(outPath);
225 fs.mkdirSync(path.dirname(path.resolve(outPath)), { recursive: true });
227 if (!opts.raw && str.startsWith('data:')) {
228 const comma = str.indexOf(',');
229 if (comma !== -1) {
230 const header = str.slice('data:'.length, comma);
231 const tokens = header.split(';').map(t => t.trim().toLowerCase());
232 if (tokens.includes('base64')) {
233 const payload = str.slice(comma + 1).replace(/\s+/g, '');
234 if (!/^[A-Za-z0-9+/]*={0,2}$/.test(payload)) {
235 throw new Error('--out: malformed base64 in data URL (decode would corrupt output)');
236 }
237 const buf = Buffer.from(payload, 'base64');
238 fs.writeFileSync(outPath, buf);
239 return buf.length;
240 }
241 }
242 }
244 const buf = Buffer.from(stripLoneSurrogates(str), 'utf-8');
245 fs.writeFileSync(outPath, buf);
246 return buf.length;
247}
249/**
250 * Extract clean text from a page (strips script/style/noscript/svg).
251 * Exported for DRY reuse in meta-commands (diff).
252 */
253export async function getCleanText(page: Page | Frame): Promise<string> {
254 const raw = await page.evaluate(() => {
255 const body = document.body;
256 if (!body) return '';
257 const clone = body.cloneNode(true) as HTMLElement;
258 clone.querySelectorAll('script, style, noscript, svg').forEach(el => el.remove());
259 return clone.innerText
260 .split('\n')
261 .map(line => line.trim())
262 .filter(line => line.length > 0)
263 .join('\n');
264 });
265 return stripLoneSurrogates(raw);
266}
268/**
269 * When cookies have been imported for specific domains, block JS execution
270 * on pages whose origin doesn't match any imported cookie domain.
271 * Prevents cross-origin cookie exfiltration via `js document.cookie` or
272 * similar when the agent navigates to an untrusted page.
273 */
274function assertJsOriginAllowed(bm: BrowserManager, pageUrl: string): void {
275 if (!bm.hasCookieImports()) return;
277 let hostname: string;
278 try {
279 hostname = new URL(pageUrl).hostname;
280 } catch {
281 return; // about:blank, data: URIs — allow (no cookies at risk)
282 }
284 const importedDomains = bm.getCookieImportedDomains();
285 const allowed = [...importedDomains].some(domain => {
286 // Exact match or subdomain match (e.g., ".github.com" matches "api.github.com")
287 const normalized = domain.startsWith('.') ? domain : '.' + domain;
288 return hostname === domain.replace(/^\./, '') || hostname.endsWith(normalized);
289 });
291 if (!allowed) {
292 throw new Error(
293 `JS execution blocked: current page (${hostname}) does not match any cookie-imported domain. ` +
294 `Imported cookies for: ${[...importedDomains].join(', ')}. ` +
295 `This prevents cross-origin cookie exfiltration. Navigate to an imported domain or run without imported cookies.`
296 );
297 }
298}
300export async function handleReadCommand(
301 command: string,
302 args: string[],
303 session: TabSession,
304 bm: BrowserManager,
305): Promise<string> {
306 const page = session.getPage();
307 // Frame-aware target for content extraction
308 const target = session.getActiveFrameOrPage();
310 switch (command) {
311 case 'text': {
312 return getCleanText(target);
313 }
315 case 'html': {
316 const selector = args[0];
317 if (selector) {
318 const resolved = await session.resolveRef(selector);
319 if ('locator' in resolved) {
320 return stripLoneSurrogates(await resolved.locator.innerHTML({ timeout: 5000 }));
321 }
322 return stripLoneSurrogates(await target.locator(resolved.selector).innerHTML({ timeout: 5000 }));
323 }
324 // page.content() is page-only; use evaluate for frame compat
325 const doctype = await target.evaluate(() => {
326 const dt = document.doctype;
327 return dt ? `<!DOCTYPE ${dt.name}>` : '';
328 });
329 const html = await target.evaluate(() => document.documentElement.outerHTML);
330 return stripLoneSurrogates(doctype ? `${doctype}\n${html}` : html);
331 }
333 case 'links': {
334 const links = await target.evaluate(() =>
335 [...document.querySelectorAll('a[href]')].map(a => ({
336 text: a.textContent?.trim().slice(0, 120) || '',
337 href: (a as HTMLAnchorElement).href,
338 })).filter(l => l.text && l.href)
339 );
340 return links.map(l => `${l.text} → ${l.href}`).join('\n');
341 }
343 case 'forms': {
344 const forms = await target.evaluate(() => {
345 return [...document.querySelectorAll('form')].map((form, i) => {
346 const fields = [...form.querySelectorAll('input, select, textarea')].map(el => {
347 const input = el as HTMLInputElement;
348 return {
349 tag: el.tagName.toLowerCase(),
350 type: input.type || undefined,
351 name: input.name || undefined,
352 id: input.id || undefined,
353 placeholder: input.placeholder || undefined,
354 required: input.required || undefined,
355 value: input.type === 'password'
356 || (input.name && /(^|[_.-])(token|secret|key|password|credential|auth|jwt|session|csrf|sid)($|[_.-])|api.?key/i.test(input.name))
357 || (input.id && /(^|[_.-])(token|secret|key|password|credential|auth|jwt|session|csrf|sid)($|[_.-])|api.?key/i.test(input.id))
358 ? '[redacted]' : (input.value || undefined),
359 options: el.tagName === 'SELECT'
360 ? [...(el as HTMLSelectElement).options].map(o => ({ value: o.value, text: o.text }))
361 : undefined,
362 };
363 });
364 return {
365 index: i,
366 action: form.action || undefined,
367 method: form.method || 'get',
368 id: form.id || undefined,
369 fields,
370 };
371 });
372 });
373 return JSON.stringify(forms, null, 2);
374 }
376 case 'accessibility': {
377 const snapshot = await target.locator("body").ariaSnapshot();
378 return stripLoneSurrogates(snapshot);
379 }
381 case 'js': {
382 const { outPath, raw, rest } = parseOutArgs(args);
383 const expr = rest[0];
384 if (!expr) throw new Error('Usage: browse js <expression> [--out <file>] [--raw]');
385 assertJsOriginAllowed(bm, page.url());
386 const wrapped = wrapForEvaluate(expr);
387 const result = await target.evaluate(wrapped);
388 const str = resultToString(result);
389 if (outPath) {
390 const n = writeEvalResult(outPath, str, { raw });
391 return `JS result written: ${outPath} (${n} bytes)`;
392 }
393 return str;
394 }
396 case 'eval': {
397 const { outPath, raw, rest } = parseOutArgs(args);
398 const filePath = rest[0];
399 if (!filePath) throw new Error('Usage: browse eval <js-file> [--out <file>] [--raw]');
400 assertJsOriginAllowed(bm, page.url());
401 validateReadPath(filePath);
402 if (!fs.existsSync(filePath)) throw new Error(`File not found: ${filePath}`);
403 const code = fs.readFileSync(filePath, 'utf-8');
404 const wrapped = wrapForEvaluate(code);
405 const result = await target.evaluate(wrapped);
406 const str = resultToString(result);
407 if (outPath) {
408 const n = writeEvalResult(outPath, str, { raw });
409 return `Eval result written: ${outPath} (${n} bytes)`;
410 }
411 return str;
412 }
414 case 'css': {
415 const [selector, property] = args;
416 if (!selector || !property) throw new Error('Usage: browse css <selector> <property>');
417 const resolved = await session.resolveRef(selector);
418 if ('locator' in resolved) {
419 const value = await resolved.locator.evaluate(
420 (el, prop) => getComputedStyle(el).getPropertyValue(prop),
421 property
422 );
423 return value;
424 }
425 const value = await target.evaluate(
426 ([sel, prop]) => {
427 const el = document.querySelector(sel);
428 if (!el) return `Element not found: ${sel}`;
429 return getComputedStyle(el).getPropertyValue(prop);
430 },
431 [resolved.selector, property]
432 );
433 return value;
434 }
436 case 'attrs': {
437 const selector = args[0];
438 if (!selector) throw new Error('Usage: browse attrs <selector>');
439 const resolved = await session.resolveRef(selector);
440 if ('locator' in resolved) {
441 const attrs = await resolved.locator.evaluate((el) => {
442 const result: Record<string, string> = {};
443 for (const attr of el.attributes) {
444 result[attr.name] = attr.value;
445 }
446 return result;
447 });
448 return JSON.stringify(attrs, null, 2);
449 }
450 const attrs = await target.evaluate((sel: string) => {
451 const el = document.querySelector(sel);
452 if (!el) return `Element not found: ${sel}`;
453 const result: Record<string, string> = {};
454 for (const attr of el.attributes) {
455 result[attr.name] = attr.value;
456 }
457 return result;
458 }, resolved.selector);
459 return typeof attrs === 'string' ? attrs : JSON.stringify(attrs, null, 2);
460 }
462 case 'console': {
463 if (args[0] === '--clear') {
464 consoleBuffer.clear();
465 return 'Console buffer cleared.';
466 }
467 const entries = args[0] === '--errors'
468 ? consoleBuffer.toArray().filter(e => e.level === 'error' || e.level === 'warning')
469 : consoleBuffer.toArray();
470 if (entries.length === 0) return args[0] === '--errors' ? '(no console errors)' : '(no console messages)';
471 return entries.map(e =>
472 `[${new Date(e.timestamp).toISOString()}] [${e.level}] ${e.text}`
473 ).join('\n');
474 }
476 case 'network': {
477 if (args[0] === '--clear') {
478 networkBuffer.clear();
479 return 'Network buffer cleared.';
480 }
482 // Network capture extensions
483 if (args[0] === '--capture') {
484 const {
485 startCapture, stopCapture, getCaptureListener, isCaptureActive,
486 } = await import('./network-capture');
488 if (args[1] === 'stop') {
489 // Detach listener from current page
490 const page = bm.getPage();
491 const listener = getCaptureListener();
492 if (listener) page.removeListener('response', listener);
493 const result = stopCapture();
494 return `Network capture stopped. ${result.count} responses captured (${result.sizeKB}KB).`;
495 }
497 // Start capture
498 if (isCaptureActive()) return 'Capture already active. Use --capture stop first.';
499 const filterIdx = args.indexOf('--filter');
500 const filterPattern = filterIdx >= 0 ? args[filterIdx + 1] : undefined;
501 const info = startCapture(filterPattern);
502 // Attach listener to current page
503 const page = bm.getPage();
504 const listener = getCaptureListener();
505 if (listener) page.on('response', listener);
506 return `Network capture started${info.filter ? ` (filter: ${info.filter})` : ''}. Use --capture stop to stop.`;
507 }
509 if (args[0] === '--export') {
510 const { exportCapture } = await import('./network-capture');
511 const { validateOutputPath: vop } = await import('./path-security');
512 const exportPath = args[1];
513 if (!exportPath) throw new Error('Usage: network --export <path>');
514 vop(exportPath);
515 const count = exportCapture(exportPath);
516 return `Exported ${count} captured responses to ${exportPath}`;
517 }
519 if (args[0] === '--bodies') {
520 const { getCaptureBuffer } = await import('./network-capture');
521 return getCaptureBuffer().summary();
522 }
524 // Default: show request metadata
525 if (networkBuffer.length === 0) return '(no network requests)';
526 return networkBuffer.toArray().map(e =>
527 `${e.method} ${e.url} → ${e.status || 'pending'} (${e.duration || '?'}ms, ${e.size || '?'}B)`
528 ).join('\n');
529 }
531 case 'dialog': {
532 if (args[0] === '--clear') {
533 dialogBuffer.clear();
534 return 'Dialog buffer cleared.';
535 }
536 if (dialogBuffer.length === 0) return '(no dialogs captured)';
537 return dialogBuffer.toArray().map(e =>
538 `[${new Date(e.timestamp).toISOString()}] [${e.type}] "${e.message}" → ${e.action}${e.response ? ` "${e.response}"` : ''}`
539 ).join('\n');
540 }
542 case 'is': {
543 const property = args[0];
544 const selector = args[1];
545 if (!property || !selector) throw new Error('Usage: browse is <property> <selector>\nProperties: visible, hidden, enabled, disabled, checked, editable, focused');
547 const resolved = await session.resolveRef(selector);
548 let locator;
549 if ('locator' in resolved) {
550 locator = resolved.locator;
551 } else {
552 locator = target.locator(resolved.selector);
553 }
555 switch (property) {
556 case 'visible': return String(await locator.isVisible());
557 case 'hidden': return String(await locator.isHidden());
558 case 'enabled': return String(await locator.isEnabled());
559 case 'disabled': return String(await locator.isDisabled());
560 case 'checked': return String(await locator.isChecked());
561 case 'editable': return String(await locator.isEditable());
562 case 'focused': {
563 const isFocused = await locator.evaluate(
564 (el) => el === document.activeElement
565 );
566 return String(isFocused);
567 }
568 default:
569 throw new Error(`Unknown property: ${property}. Use: visible, hidden, enabled, disabled, checked, editable, focused`);
570 }
571 }
573 case 'cookies': {
574 const cookies = await page.context().cookies();
575 // Redact cookie values that look like secrets (consistent with storage redaction)
576 const redacted = cookies.map(c => {
577 if (SENSITIVE_COOKIE_NAME.test(c.name) || SENSITIVE_COOKIE_VALUE.test(c.value)) {
578 return { ...c, value: `[REDACTED — ${c.value.length} chars]` };
579 }
580 return c;
581 });
582 return JSON.stringify(redacted, null, 2);
583 }
585 case 'storage': {
586 if (args[0] === 'set' && args[1]) {
587 const key = args[1];
588 const value = args[2] || '';
589 await target.evaluate(([k, v]: string[]) => localStorage.setItem(k, v), [key, value]);
590 return `Set localStorage["${key}"]`;
591 }
592 const storage = await target.evaluate(() => ({
593 localStorage: { ...localStorage },
594 sessionStorage: { ...sessionStorage },
595 }));
596 // Redact values that look like secrets (tokens, keys, passwords, JWTs)
597 const SENSITIVE_KEY = /(^|[_.-])(token|secret|key|password|credential|auth|jwt|session|csrf)($|[_.-])|api.?key/i;
598 const SENSITIVE_VALUE = /^(eyJ|sk-|sk_live_|sk_test_|pk_live_|pk_test_|rk_live_|sk-ant-|ghp_|gho_|github_pat_|xox[bpsa]-|AKIA[A-Z0-9]{16}|AIza|SG\.|Bearer\s|sbp_)/;
599 const redacted = JSON.parse(JSON.stringify(storage));
600 for (const storeType of ['localStorage', 'sessionStorage'] as const) {
601 const store = redacted[storeType];
602 if (!store) continue;
603 for (const [key, value] of Object.entries(store)) {
604 if (typeof value !== 'string') continue;
605 if (SENSITIVE_KEY.test(key) || SENSITIVE_VALUE.test(value)) {
606 store[key] = `[REDACTED — ${value.length} chars]`;
607 }
608 }
609 }
610 return JSON.stringify(redacted, null, 2);
611 }
613 case 'perf': {
614 const timings = await page.evaluate(() => {
615 const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
616 if (!nav) return 'No navigation timing data available.';
617 return {
618 dns: Math.round(nav.domainLookupEnd - nav.domainLookupStart),
619 tcp: Math.round(nav.connectEnd - nav.connectStart),
620 ssl: Math.round(nav.secureConnectionStart > 0 ? nav.connectEnd - nav.secureConnectionStart : 0),
621 ttfb: Math.round(nav.responseStart - nav.requestStart),
622 download: Math.round(nav.responseEnd - nav.responseStart),
623 domParse: Math.round(nav.domInteractive - nav.responseEnd),
624 domReady: Math.round(nav.domContentLoadedEventEnd - nav.startTime),
625 load: Math.round(nav.loadEventEnd - nav.startTime),
626 total: Math.round(nav.loadEventEnd - nav.startTime),
627 };
628 });
629 if (typeof timings === 'string') return timings;
630 return Object.entries(timings)
631 .map(([k, v]) => `${k.padEnd(12)} ${v}ms`)
632 .join('\n');
633 }
635 case 'inspect': {
636 // Parse flags
637 let includeUA = false;
638 let showHistory = false;
639 let selector: string | undefined;
641 for (const arg of args) {
642 if (arg === '--all') {
643 includeUA = true;
644 } else if (arg === '--history') {
645 showHistory = true;
646 } else if (!selector) {
647 selector = arg;
648 }
649 }
651 // --history mode: return modification history
652 if (showHistory) {
653 const history = getModificationHistory();
654 if (history.length === 0) return '(no style modifications)';
655 return history.map((m, i) =>
656 `[${i}] ${m.selector} { ${m.property}: ${m.oldValue} → ${m.newValue} } (${m.source}, ${m.method})`
657 ).join('\n');
658 }
660 // If no selector given, check for stored inspector data
661 if (!selector) {
662 // Access stored inspector data from the server's in-memory state
663 // The server stores this when the extension picks an element via POST /inspector/pick
664 const stored = (bm as any)._inspectorData;
665 const storedTs = (bm as any)._inspectorTimestamp;
666 if (stored) {
667 const stale = storedTs && (Date.now() - storedTs > 60000);
668 let output = formatInspectorResult(stored, { includeUA });
669 if (stale) output = '⚠ Data may be stale (>60s old)\n\n' + output;
670 return output;
671 }
672 throw new Error('Usage: browse inspect [selector] [--all] [--history]\nOr pick an element in the Chrome sidebar first.');
673 }
675 // Direct inspection by selector
676 const result = await inspectElement(page, selector, { includeUA });
677 // Store for later retrieval
678 (bm as any)._inspectorData = result;
679 (bm as any)._inspectorTimestamp = Date.now();
680 return formatInspectorResult(result, { includeUA });
681 }
683 case 'media': {
684 const { extractMedia } = await import('./media-extract');
685 const target = bm.getActiveFrameOrPage();
686 const filter = args.includes('--images') ? 'images' as const
687 : args.includes('--videos') ? 'videos' as const
688 : args.includes('--audio') ? 'audio' as const
689 : undefined;
690 const selectorArg = args.find(a => !a.startsWith('--'));
691 const result = await extractMedia(target, { selector: selectorArg, filter });
692 return JSON.stringify(result, null, 2);
693 }
695 case 'data': {
696 const target = bm.getActiveFrameOrPage();
697 const wantJsonLd = args.includes('--jsonld') || args.length === 0;
698 const wantOg = args.includes('--og') || args.length === 0;
699 const wantTwitter = args.includes('--twitter') || args.length === 0;
700 const wantMeta = args.includes('--meta') || args.length === 0;
702 const result = await target.evaluate(({ wantJsonLd, wantOg, wantTwitter, wantMeta }) => {
703 const data: Record<string, any> = {};
705 if (wantJsonLd) {
706 const scripts = document.querySelectorAll('script[type="application/ld+json"]');
707 const jsonLd: any[] = [];
708 scripts.forEach(s => {
709 try { jsonLd.push(JSON.parse(s.textContent || '')); } catch {}
710 });
711 data.jsonLd = jsonLd;
712 }
714 if (wantOg) {
715 const og: Record<string, string> = {};
716 document.querySelectorAll('meta[property^="og:"]').forEach(m => {
717 const prop = m.getAttribute('property')?.replace('og:', '') || '';
718 og[prop] = m.getAttribute('content') || '';
719 });
720 data.openGraph = og;
721 }
723 if (wantTwitter) {
724 const tw: Record<string, string> = {};
725 document.querySelectorAll('meta[name^="twitter:"]').forEach(m => {
726 const name = m.getAttribute('name')?.replace('twitter:', '') || '';
727 tw[name] = m.getAttribute('content') || '';
728 });
729 data.twitterCards = tw;
730 }
732 if (wantMeta) {
733 const meta: Record<string, string> = {};
734 const canonical = document.querySelector('link[rel="canonical"]');
735 if (canonical) meta.canonical = canonical.getAttribute('href') || '';
736 const desc = document.querySelector('meta[name="description"]');
737 if (desc) meta.description = desc.getAttribute('content') || '';
738 const keywords = document.querySelector('meta[name="keywords"]');
739 if (keywords) meta.keywords = keywords.getAttribute('content') || '';
740 const author = document.querySelector('meta[name="author"]');
741 if (author) meta.author = author.getAttribute('content') || '';
742 const title = document.querySelector('title');
743 if (title) meta.title = title.textContent || '';
744 data.meta = meta;
745 }
747 return data;
748 }, { wantJsonLd, wantOg, wantTwitter, wantMeta });
750 return JSON.stringify(result, null, 2);
751 }
753 default:
754 throw new Error(`Unknown read command: ${command}`);
755 }
756}