/** * Read commands — extract data from pages without side effects * * text, html, links, forms, accessibility, js, eval, css, attrs, * console, network, cookies, storage, perf */ import type { BrowserManager } from './browser-manager'; import { consoleBuffer, networkBuffer, dialogBuffer } from './buffers'; import type { Page } from 'playwright'; import * as fs from 'fs'; import * as path from 'path'; /** Detect await keyword, ignoring comments. Accepted risk: await in string literals triggers wrapping (harmless). */ function hasAwait(code: string): boolean { const stripped = code.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, ''); return /\bawait\b/.test(stripped); } // Security: Path validation to prevent path traversal attacks const SAFE_DIRECTORIES = ['/tmp', process.cwd()]; function validateReadPath(filePath: string): void { if (path.isAbsolute(filePath)) { const resolved = path.resolve(filePath); const isSafe = SAFE_DIRECTORIES.some(dir => resolved === dir || resolved.startsWith(dir + '/')); if (!isSafe) { throw new Error(`Absolute path must be within: ${SAFE_DIRECTORIES.join(', ')}`); } } const normalized = path.normalize(filePath); if (normalized.includes('..')) { throw new Error('Path traversal sequences (..) are not allowed'); } } /** * Extract clean text from a page (strips script/style/noscript/svg). * Exported for DRY reuse in meta-commands (diff). */ export async function getCleanText(page: Page): Promise { return await page.evaluate(() => { const body = document.body; if (!body) return ''; const clone = body.cloneNode(true) as HTMLElement; clone.querySelectorAll('script, style, noscript, svg').forEach(el => el.remove()); return clone.innerText .split('\n') .map(line => line.trim()) .filter(line => line.length > 0) .join('\n'); }); } export async function handleReadCommand( command: string, args: string[], bm: BrowserManager ): Promise { const page = bm.getPage(); switch (command) { case 'text': { return await getCleanText(page); } case 'html': { const selector = args[0]; if (selector) { const resolved = await bm.resolveRef(selector); if ('locator' in resolved) { return await resolved.locator.innerHTML({ timeout: 5000 }); } return await page.innerHTML(resolved.selector); } return await page.content(); } case 'links': { const links = await page.evaluate(() => [...document.querySelectorAll('a[href]')].map(a => ({ text: a.textContent?.trim().slice(0, 120) || '', href: (a as HTMLAnchorElement).href, })).filter(l => l.text && l.href) ); return links.map(l => `${l.text} → ${l.href}`).join('\n'); } case 'forms': { const forms = await page.evaluate(() => { return [...document.querySelectorAll('form')].map((form, i) => { const fields = [...form.querySelectorAll('input, select, textarea')].map(el => { const input = el as HTMLInputElement; return { tag: el.tagName.toLowerCase(), type: input.type || undefined, name: input.name || undefined, id: input.id || undefined, placeholder: input.placeholder || undefined, required: input.required || undefined, value: input.type === 'password' ? '[redacted]' : (input.value || undefined), options: el.tagName === 'SELECT' ? [...(el as HTMLSelectElement).options].map(o => ({ value: o.value, text: o.text })) : undefined, }; }); return { index: i, action: form.action || undefined, method: form.method || 'get', id: form.id || undefined, fields, }; }); }); return JSON.stringify(forms, null, 2); } case 'accessibility': { const snapshot = await page.locator("body").ariaSnapshot(); return snapshot; } case 'js': { const expr = args[0]; if (!expr) throw new Error('Usage: browse js '); const wrapped = hasAwait(expr) ? `(async()=>(${expr}))()` : expr; const result = await page.evaluate(wrapped); return typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result ?? ''); } case 'eval': { const filePath = args[0]; if (!filePath) throw new Error('Usage: browse eval '); validateReadPath(filePath); if (!fs.existsSync(filePath)) throw new Error(`File not found: ${filePath}`); const code = fs.readFileSync(filePath, 'utf-8'); if (hasAwait(code)) { const trimmed = code.trim(); const isSingleExpr = trimmed.split('\n').length === 1; const wrapped = isSingleExpr ? `(async()=>(${trimmed}))()` : `(async()=>{\n${code}\n})()`; const result = await page.evaluate(wrapped); return typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result ?? ''); } const result = await page.evaluate(code); return typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result ?? ''); } case 'css': { const [selector, property] = args; if (!selector || !property) throw new Error('Usage: browse css '); const resolved = await bm.resolveRef(selector); if ('locator' in resolved) { const value = await resolved.locator.evaluate( (el, prop) => getComputedStyle(el).getPropertyValue(prop), property ); return value; } const value = await page.evaluate( ([sel, prop]) => { const el = document.querySelector(sel); if (!el) return `Element not found: ${sel}`; return getComputedStyle(el).getPropertyValue(prop); }, [resolved.selector, property] ); return value; } case 'attrs': { const selector = args[0]; if (!selector) throw new Error('Usage: browse attrs '); const resolved = await bm.resolveRef(selector); if ('locator' in resolved) { const attrs = await resolved.locator.evaluate((el) => { const result: Record = {}; for (const attr of el.attributes) { result[attr.name] = attr.value; } return result; }); return JSON.stringify(attrs, null, 2); } const attrs = await page.evaluate((sel) => { const el = document.querySelector(sel); if (!el) return `Element not found: ${sel}`; const result: Record = {}; for (const attr of el.attributes) { result[attr.name] = attr.value; } return result; }, resolved.selector); return typeof attrs === 'string' ? attrs : JSON.stringify(attrs, null, 2); } case 'console': { if (args[0] === '--clear') { consoleBuffer.clear(); return 'Console buffer cleared.'; } const entries = args[0] === '--errors' ? consoleBuffer.toArray().filter(e => e.level === 'error' || e.level === 'warning') : consoleBuffer.toArray(); if (entries.length === 0) return args[0] === '--errors' ? '(no console errors)' : '(no console messages)'; return entries.map(e => `[${new Date(e.timestamp).toISOString()}] [${e.level}] ${e.text}` ).join('\n'); } case 'network': { if (args[0] === '--clear') { networkBuffer.clear(); return 'Network buffer cleared.'; } if (networkBuffer.length === 0) return '(no network requests)'; return networkBuffer.toArray().map(e => `${e.method} ${e.url} → ${e.status || 'pending'} (${e.duration || '?'}ms, ${e.size || '?'}B)` ).join('\n'); } case 'dialog': { if (args[0] === '--clear') { dialogBuffer.clear(); return 'Dialog buffer cleared.'; } if (dialogBuffer.length === 0) return '(no dialogs captured)'; return dialogBuffer.toArray().map(e => `[${new Date(e.timestamp).toISOString()}] [${e.type}] "${e.message}" → ${e.action}${e.response ? ` "${e.response}"` : ''}` ).join('\n'); } case 'is': { const property = args[0]; const selector = args[1]; if (!property || !selector) throw new Error('Usage: browse is \nProperties: visible, hidden, enabled, disabled, checked, editable, focused'); const resolved = await bm.resolveRef(selector); let locator; if ('locator' in resolved) { locator = resolved.locator; } else { locator = page.locator(resolved.selector); } switch (property) { case 'visible': return String(await locator.isVisible()); case 'hidden': return String(await locator.isHidden()); case 'enabled': return String(await locator.isEnabled()); case 'disabled': return String(await locator.isDisabled()); case 'checked': return String(await locator.isChecked()); case 'editable': return String(await locator.isEditable()); case 'focused': { const isFocused = await locator.evaluate( (el) => el === document.activeElement ); return String(isFocused); } default: throw new Error(`Unknown property: ${property}. Use: visible, hidden, enabled, disabled, checked, editable, focused`); } } case 'cookies': { const cookies = await page.context().cookies(); return JSON.stringify(cookies, null, 2); } case 'storage': { if (args[0] === 'set' && args[1]) { const key = args[1]; const value = args[2] || ''; await page.evaluate(([k, v]) => localStorage.setItem(k, v), [key, value]); return `Set localStorage["${key}"]`; } const storage = await page.evaluate(() => ({ localStorage: { ...localStorage }, sessionStorage: { ...sessionStorage }, })); return JSON.stringify(storage, null, 2); } case 'perf': { const timings = await page.evaluate(() => { const nav = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming; if (!nav) return 'No navigation timing data available.'; return { dns: Math.round(nav.domainLookupEnd - nav.domainLookupStart), tcp: Math.round(nav.connectEnd - nav.connectStart), ssl: Math.round(nav.secureConnectionStart > 0 ? nav.connectEnd - nav.secureConnectionStart : 0), ttfb: Math.round(nav.responseStart - nav.requestStart), download: Math.round(nav.responseEnd - nav.responseStart), domParse: Math.round(nav.domInteractive - nav.responseEnd), domReady: Math.round(nav.domContentLoadedEventEnd - nav.startTime), load: Math.round(nav.loadEventEnd - nav.startTime), total: Math.round(nav.loadEventEnd - nav.startTime), }; }); if (typeof timings === 'string') return timings; return Object.entries(timings) .map(([k, v]) => `${k.padEnd(12)} ${v}ms`) .join('\n'); } default: throw new Error(`Unknown read command: ${command}`); } }