From 9e03049de1d0832d997cf21bd0af60be1c0ce2b9 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Wed, 11 Mar 2026 14:23:07 -0700 Subject: [PATCH] feat: 40+ browser commands (read, write, meta) Read: text, html, links, forms, accessibility, js, eval, css, attrs, console, network, cookies, storage, perf Write: goto, back, forward, reload, click, fill, select, hover, type, press, scroll, wait, viewport, cookie, header, useragent Meta: tabs, tab, newtab, closetab, status, url, stop, restart, screenshot, pdf, responsive, chain, diff Co-Authored-By: Claude Opus 4.6 --- src/meta-commands.ts | 198 ++++++++++++++++++++++++++++++++++++++++++ src/read-commands.ts | 198 ++++++++++++++++++++++++++++++++++++++++++ src/write-commands.ts | 149 +++++++++++++++++++++++++++++++ 3 files changed, 545 insertions(+) create mode 100644 src/meta-commands.ts create mode 100644 src/read-commands.ts create mode 100644 src/write-commands.ts diff --git a/src/meta-commands.ts b/src/meta-commands.ts new file mode 100644 index 00000000..8cb56e91 --- /dev/null +++ b/src/meta-commands.ts @@ -0,0 +1,198 @@ +/** + * Meta commands — tabs, server control, screenshots, chain, diff + */ + +import type { BrowserManager } from './browser-manager'; +import * as Diff from 'diff'; +import * as fs from 'fs'; + +export async function handleMetaCommand( + command: string, + args: string[], + bm: BrowserManager, + shutdown: () => Promise | void +): Promise { + switch (command) { + // ─── Tabs ────────────────────────────────────────── + case 'tabs': { + const tabs = await bm.getTabListWithTitles(); + return tabs.map(t => + `${t.active ? '→ ' : ' '}[${t.id}] ${t.title || '(untitled)'} — ${t.url}` + ).join('\n'); + } + + case 'tab': { + const id = parseInt(args[0], 10); + if (isNaN(id)) throw new Error('Usage: browse tab '); + bm.switchTab(id); + return `Switched to tab ${id}`; + } + + case 'newtab': { + const url = args[0]; + const id = await bm.newTab(url); + return `Opened tab ${id}${url ? ` → ${url}` : ''}`; + } + + case 'closetab': { + const id = args[0] ? parseInt(args[0], 10) : undefined; + await bm.closeTab(id); + return `Closed tab${id ? ` ${id}` : ''}`; + } + + // ─── Server Control ──────────────────────────────── + case 'status': { + const page = bm.getPage(); + const tabs = bm.getTabCount(); + return [ + `Status: healthy`, + `URL: ${page.url()}`, + `Tabs: ${tabs}`, + `PID: ${process.pid}`, + ].join('\n'); + } + + case 'url': { + return bm.getCurrentUrl(); + } + + case 'stop': { + await shutdown(); + return 'Server stopped'; + } + + case 'restart': { + // Signal that we want a restart — the CLI will detect exit and restart + console.log('[browse] Restart requested. Exiting for CLI to restart.'); + await shutdown(); + return 'Restarting...'; + } + + // ─── Visual ──────────────────────────────────────── + case 'screenshot': { + const page = bm.getPage(); + const screenshotPath = args[0] || '/tmp/browse-screenshot.png'; + await page.screenshot({ path: screenshotPath, fullPage: true }); + return `Screenshot saved: ${screenshotPath}`; + } + + case 'pdf': { + const page = bm.getPage(); + const pdfPath = args[0] || '/tmp/browse-page.pdf'; + await page.pdf({ path: pdfPath, format: 'A4' }); + return `PDF saved: ${pdfPath}`; + } + + case 'responsive': { + const page = bm.getPage(); + const prefix = args[0] || '/tmp/browse-responsive'; + const viewports = [ + { name: 'mobile', width: 375, height: 812 }, + { name: 'tablet', width: 768, height: 1024 }, + { name: 'desktop', width: 1280, height: 720 }, + ]; + const originalViewport = page.viewportSize(); + const results: string[] = []; + + for (const vp of viewports) { + await page.setViewportSize({ width: vp.width, height: vp.height }); + const path = `${prefix}-${vp.name}.png`; + await page.screenshot({ path, fullPage: true }); + results.push(`${vp.name} (${vp.width}x${vp.height}): ${path}`); + } + + // Restore original viewport + if (originalViewport) { + await page.setViewportSize(originalViewport); + } + + return results.join('\n'); + } + + // ─── Chain ───────────────────────────────────────── + case 'chain': { + // Read JSON array from args[0] (if provided) or expect it was passed as body + const jsonStr = args[0]; + if (!jsonStr) throw new Error('Usage: echo \'[["goto","url"],["text"]]\' | browse chain'); + + let commands: string[][]; + try { + commands = JSON.parse(jsonStr); + } catch { + throw new Error('Invalid JSON. Expected: [["command", "arg1", "arg2"], ...]'); + } + + if (!Array.isArray(commands)) throw new Error('Expected JSON array of commands'); + + const results: string[] = []; + // Import handlers dynamically to avoid circular deps + const { handleReadCommand } = await import('./read-commands'); + const { handleWriteCommand } = await import('./write-commands'); + + for (const cmd of commands) { + const [name, ...cmdArgs] = cmd; + try { + // Try each command type + let result: string; + try { + result = await handleWriteCommand(name, cmdArgs, bm); + } catch { + try { + result = await handleReadCommand(name, cmdArgs, bm); + } catch { + result = await handleMetaCommand(name, cmdArgs, bm, shutdown); + } + } + results.push(`[${name}] ${result}`); + } catch (err: any) { + results.push(`[${name}] ERROR: ${err.message}`); + } + } + + return results.join('\n\n'); + } + + // ─── Diff ────────────────────────────────────────── + case 'diff': { + const [url1, url2] = args; + if (!url1 || !url2) throw new Error('Usage: browse diff '); + + // Get text from URL1 + const page = bm.getPage(); + await page.goto(url1, { waitUntil: 'domcontentloaded', timeout: 15000 }); + const text1 = 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(l => l.trim()).filter(l => l).join('\n'); + }); + + // Get text from URL2 + await page.goto(url2, { waitUntil: 'domcontentloaded', timeout: 15000 }); + const text2 = 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(l => l.trim()).filter(l => l).join('\n'); + }); + + const changes = Diff.diffLines(text1, text2); + const output: string[] = [`--- ${url1}`, `+++ ${url2}`, '']; + + for (const part of changes) { + const prefix = part.added ? '+' : part.removed ? '-' : ' '; + const lines = part.value.split('\n').filter(l => l.length > 0); + for (const line of lines) { + output.push(`${prefix} ${line}`); + } + } + + return output.join('\n'); + } + + default: + throw new Error(`Unknown meta command: ${command}`); + } +} diff --git a/src/read-commands.ts b/src/read-commands.ts new file mode 100644 index 00000000..8932ee05 --- /dev/null +++ b/src/read-commands.ts @@ -0,0 +1,198 @@ +/** + * 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 } from './buffers'; +import * as fs from 'fs'; + +export async function handleReadCommand( + command: string, + args: string[], + bm: BrowserManager +): Promise { + const page = bm.getPage(); + + switch (command) { + case 'text': { + 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'); + }); + } + + case 'html': { + const selector = args[0]; + if (selector) { + return await page.innerHTML(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.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 result = await page.evaluate(expr); + 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 '); + if (!fs.existsSync(filePath)) throw new Error(`File not found: ${filePath}`); + const code = fs.readFileSync(filePath, 'utf-8'); + 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 value = await page.evaluate( + ([sel, prop]) => { + const el = document.querySelector(sel); + if (!el) return `Element not found: ${sel}`; + return getComputedStyle(el).getPropertyValue(prop); + }, + [selector, property] + ); + return value; + } + + case 'attrs': { + const selector = args[0]; + if (!selector) throw new Error('Usage: browse attrs '); + 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; + }, selector); + return typeof attrs === 'string' ? attrs : JSON.stringify(attrs, null, 2); + } + + case 'console': { + if (args[0] === '--clear') { + consoleBuffer.length = 0; + return 'Console buffer cleared.'; + } + if (consoleBuffer.length === 0) return '(no console messages)'; + return consoleBuffer.map(e => + `[${new Date(e.timestamp).toISOString()}] [${e.level}] ${e.text}` + ).join('\n'); + } + + case 'network': { + if (args[0] === '--clear') { + networkBuffer.length = 0; + return 'Network buffer cleared.'; + } + if (networkBuffer.length === 0) return '(no network requests)'; + return networkBuffer.map(e => + `${e.method} ${e.url} → ${e.status || 'pending'} (${e.duration || '?'}ms, ${e.size || '?'}B)` + ).join('\n'); + } + + 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}"] = "${value}"`; + } + 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}`); + } +} diff --git a/src/write-commands.ts b/src/write-commands.ts new file mode 100644 index 00000000..ad77ba02 --- /dev/null +++ b/src/write-commands.ts @@ -0,0 +1,149 @@ +/** + * Write commands — navigate and interact with pages (side effects) + * + * goto, back, forward, reload, click, fill, select, hover, type, + * press, scroll, wait, viewport, cookie, header, useragent + */ + +import type { BrowserManager } from './browser-manager'; + +export async function handleWriteCommand( + command: string, + args: string[], + bm: BrowserManager +): Promise { + const page = bm.getPage(); + + switch (command) { + case 'goto': { + const url = args[0]; + if (!url) throw new Error('Usage: browse goto '); + const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 15000 }); + const status = response?.status() || 'unknown'; + return `Navigated to ${url} (${status})`; + } + + case 'back': { + await page.goBack({ waitUntil: 'domcontentloaded', timeout: 15000 }); + return `Back → ${page.url()}`; + } + + case 'forward': { + await page.goForward({ waitUntil: 'domcontentloaded', timeout: 15000 }); + return `Forward → ${page.url()}`; + } + + case 'reload': { + await page.reload({ waitUntil: 'domcontentloaded', timeout: 15000 }); + return `Reloaded ${page.url()}`; + } + + case 'click': { + const selector = args[0]; + if (!selector) throw new Error('Usage: browse click '); + await page.click(selector, { timeout: 5000 }); + // Wait briefly for any navigation/DOM update + await page.waitForLoadState('domcontentloaded').catch(() => {}); + return `Clicked ${selector} → now at ${page.url()}`; + } + + case 'fill': { + const [selector, ...valueParts] = args; + const value = valueParts.join(' '); + if (!selector || !value) throw new Error('Usage: browse fill '); + await page.fill(selector, value, { timeout: 5000 }); + return `Filled ${selector}`; + } + + case 'select': { + const [selector, ...valueParts] = args; + const value = valueParts.join(' '); + if (!selector || !value) throw new Error('Usage: browse select '); + await page.selectOption(selector, value, { timeout: 5000 }); + return `Selected "${value}" in ${selector}`; + } + + case 'hover': { + const selector = args[0]; + if (!selector) throw new Error('Usage: browse hover '); + await page.hover(selector, { timeout: 5000 }); + return `Hovered ${selector}`; + } + + case 'type': { + const text = args.join(' '); + if (!text) throw new Error('Usage: browse type '); + await page.keyboard.type(text); + return `Typed "${text}"`; + } + + case 'press': { + const key = args[0]; + if (!key) throw new Error('Usage: browse press (e.g., Enter, Tab, Escape)'); + await page.keyboard.press(key); + return `Pressed ${key}`; + } + + case 'scroll': { + const selector = args[0]; + if (selector) { + await page.locator(selector).scrollIntoViewIfNeeded({ timeout: 5000 }); + return `Scrolled ${selector} into view`; + } + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); + return 'Scrolled to bottom'; + } + + case 'wait': { + const selector = args[0]; + if (!selector) throw new Error('Usage: browse wait '); + const timeout = args[1] ? parseInt(args[1], 10) : 15000; + await page.waitForSelector(selector, { timeout }); + return `Element ${selector} appeared`; + } + + case 'viewport': { + const size = args[0]; + if (!size || !size.includes('x')) throw new Error('Usage: browse viewport (e.g., 375x812)'); + const [w, h] = size.split('x').map(Number); + await bm.setViewport(w, h); + return `Viewport set to ${w}x${h}`; + } + + case 'cookie': { + const cookieStr = args[0]; + if (!cookieStr || !cookieStr.includes('=')) throw new Error('Usage: browse cookie ='); + const eq = cookieStr.indexOf('='); + const name = cookieStr.slice(0, eq); + const value = cookieStr.slice(eq + 1); + const url = new URL(page.url()); + await page.context().addCookies([{ + name, + value, + domain: url.hostname, + path: '/', + }]); + return `Cookie set: ${name}=${value}`; + } + + case 'header': { + const headerStr = args[0]; + if (!headerStr || !headerStr.includes(':')) throw new Error('Usage: browse header :'); + const sep = headerStr.indexOf(':'); + const name = headerStr.slice(0, sep).trim(); + const value = headerStr.slice(sep + 1).trim(); + await bm.setExtraHeader(name, value); + return `Header set: ${name}: ${value}`; + } + + case 'useragent': { + const ua = args.join(' '); + if (!ua) throw new Error('Usage: browse useragent '); + bm.setUserAgent(ua); + return `User agent set (applies on next restart): ${ua}`; + } + + default: + throw new Error(`Unknown write command: ${command}`); + } +}