#!/usr/bin/env node /** * GrokBoy Playwright DOM helper (optional). * * Modes: * JSONL daemon (default when no --cmd): read JSON lines from stdin, write JSON lines to stdout. * One-shot: node browser_helper.mjs --cmd '' * Self-test: node browser_helper.mjs --self-test (no Chromium required) * * Ops: ping, navigate, snapshot, click, type, eval, close, status * Prefer CSS selector or role+name. Screenshots are NOT the primary control surface. */ import { createInterface } from "node:readline"; import { dirname } from "node:path"; import { fileURLToPath } from "node:url"; const __dirname = dirname(fileURLToPath(import.meta.url)); const INSTALL_HINT = "Playwright not available. From the GrokBoy repo run: " + "cd tools/playwright && npm install && npx playwright install chromium"; let browser = null; let context = null; let page = null; let lastUrl = null; function ok(id, extra = {}) { return { id: id ?? null, ok: true, ...extra }; } function fail(id, error, extra = {}) { return { id: id ?? null, ok: false, blocked: true, error: String(error), ...extra, }; } async function loadPlaywright() { try { return await import("playwright"); } catch (e) { const err = new Error(`${INSTALL_HINT} (import failed: ${e.message})`); err.code = "PLAYWRIGHT_MISSING"; throw err; } } async function ensurePage() { if (page) return page; const { chromium } = await loadPlaywright(); try { browser = await chromium.launch({ headless: true }); } catch (e) { const err = new Error( `${INSTALL_HINT} (chromium launch failed: ${e.message})` ); err.code = "CHROMIUM_MISSING"; throw err; } context = await browser.newContext(); page = await context.newPage(); return page; } /** Accessibility-ish text snapshot: roles, names, and useful selectors — not pixels. */ async function buildSnapshot(p) { const nodes = await p.evaluate(() => { const out = []; const max = 400; const interesting = new Set([ "a", "button", "input", "textarea", "select", "option", "h1", "h2", "h3", "h4", "h5", "h6", "label", "li", "summary", "nav", "main", "form", "img", "table", "th", "td", ]); function cssPath(el) { if (el.id) return `#${CSS.escape(el.id)}`; const parts = []; let cur = el; while (cur && cur.nodeType === 1 && parts.length < 5) { let part = cur.tagName.toLowerCase(); if (cur.id) { parts.unshift(`#${CSS.escape(cur.id)}`); break; } const parent = cur.parentElement; if (parent) { const siblings = [...parent.children].filter( (c) => c.tagName === cur.tagName ); if (siblings.length > 1) { const idx = siblings.indexOf(cur) + 1; part += `:nth-of-type(${idx})`; } } parts.unshift(part); cur = parent; } return parts.join(" > "); } const walker = document.createTreeWalker( document.body || document.documentElement, NodeFilter.SHOW_ELEMENT ); let node = walker.currentNode; while (node && out.length < max) { const tag = node.tagName ? node.tagName.toLowerCase() : ""; const role = node.getAttribute("role") || (tag === "a" ? "link" : tag === "button" ? "button" : tag === "input" ? node.getAttribute("type") === "submit" ? "button" : "textbox" : tag === "textarea" ? "textbox" : tag === "img" ? "img" : tag.startsWith("h") && tag.length === 2 ? "heading" : null); const name = node.getAttribute("aria-label") || node.getAttribute("alt") || node.getAttribute("placeholder") || node.getAttribute("title") || (node.innerText || node.textContent || "").trim().slice(0, 120); const href = node.getAttribute && node.getAttribute("href"); const type = node.getAttribute && node.getAttribute("type"); const value = "value" in node && typeof node.value === "string" ? String(node.value).slice(0, 80) : null; if (interesting.has(tag) || node.getAttribute("role") || node.id) { const entry = { tag, role: role || tag, name: name || "", selector: cssPath(node), }; if (href) entry.href = href; if (type) entry.type = type; if (value) entry.value = value; if (node.id) entry.id = node.id; out.push(entry); } node = walker.nextNode(); } return out; }); const title = await p.title(); const url = p.url(); const lines = nodes.map((n, i) => { const bits = [`[${i}]`, n.role || n.tag]; if (n.name) bits.push(`name=${JSON.stringify(n.name)}`); if (n.selector) bits.push(`sel=${n.selector}`); if (n.href) bits.push(`href=${n.href}`); if (n.type) bits.push(`type=${n.type}`); if (n.value) bits.push(`value=${JSON.stringify(n.value)}`); return bits.join(" "); }); return { url, title, count: nodes.length, text: lines.join("\n"), nodes, }; } async function resolveLocator(p, args) { if (args.selector) { return p.locator(String(args.selector)).first(); } if (args.role) { const opts = {}; if (args.name) opts.name = String(args.name); return p.getByRole(String(args.role), opts).first(); } if (args.text) { return p.getByText(String(args.text), { exact: !!args.exact }).first(); } if (args.label) { return p.getByLabel(String(args.label)).first(); } if (args.placeholder) { return p.getByPlaceholder(String(args.placeholder)).first(); } throw new Error( "click/type requires selector, role(+name), text, label, or placeholder" ); } async function handle(req) { const id = req.id ?? null; const op = req.op || req.command; if (!op) return fail(id, "missing op"); try { switch (op) { case "ping": return ok(id, { pong: true, protocol: 1, ops: [ "ping", "navigate", "snapshot", "click", "type", "eval", "close", "status", ], }); case "status": { return ok(id, { browser_open: !!browser, last_url: lastUrl, page_url: page ? page.url() : null, }); } case "navigate": { const url = req.url; if (!url) return fail(id, "navigate: missing url"); const p = await ensurePage(); const resp = await p.goto(String(url), { waitUntil: "domcontentloaded", timeout: req.timeout_ms ?? 30000, }); lastUrl = p.url(); return ok(id, { url: lastUrl, title: await p.title(), status: resp ? resp.status() : null, }); } case "snapshot": case "dom": { const p = await ensurePage(); const snap = await buildSnapshot(p); lastUrl = snap.url; return ok(id, { url: snap.url, title: snap.title, count: snap.count, snapshot: snap.text, nodes: snap.nodes.slice(0, 200), }); } case "click": { const p = await ensurePage(); const loc = await resolveLocator(p, req); await loc.click({ timeout: req.timeout_ms ?? 10000 }); lastUrl = p.url(); return ok(id, { clicked: true, url: lastUrl }); } case "type": { const p = await ensurePage(); const text = req.text ?? req.value; if (text == null) return fail(id, "type: missing text"); const loc = await resolveLocator(p, req); if (req.clear !== false) { await loc.fill(String(text), { timeout: req.timeout_ms ?? 10000 }); } else { await loc.type(String(text), { timeout: req.timeout_ms ?? 10000 }); } lastUrl = p.url(); return ok(id, { typed: true, url: lastUrl }); } case "eval": { const p = await ensurePage(); const expression = req.expression ?? req.js ?? req.code; if (!expression) return fail(id, "eval: missing expression"); // Evaluate as expression body; fail closed on throw. const result = await p.evaluate((expr) => { // eslint-disable-next-line no-eval return eval(expr); }, String(expression)); let serialized; try { serialized = JSON.parse(JSON.stringify(result)); } catch { serialized = String(result); } return ok(id, { result: serialized, url: p.url() }); } case "close": { if (browser) { await browser.close().catch(() => {}); } browser = null; context = null; page = null; return ok(id, { closed: true, last_url: lastUrl }); } default: return fail(id, `unknown op: ${op}`); } } catch (e) { const extra = {}; if (e && e.code) extra.code = e.code; return fail(id, e.message || e, extra); } } function parseArgs(argv) { const out = { cmd: null, selfTest: false, daemon: true }; for (let i = 2; i < argv.length; i++) { const a = argv[i]; if (a === "--self-test") out.selfTest = true; else if (a === "--cmd" || a === "-c") { out.cmd = argv[++i]; out.daemon = false; } else if (a === "--daemon") out.daemon = true; else if (a === "--help" || a === "-h") out.help = true; } return out; } async function runSelfTest() { const cases = []; // Protocol: ping without playwright let r = await handle({ id: "t1", op: "ping" }); cases.push(["ping", r.ok === true && r.pong === true]); // Missing fields fail closed r = await handle({ id: "t2", op: "navigate" }); cases.push(["navigate_missing_url", r.ok === false && r.blocked === true]); r = await handle({ id: "t3", op: "type", selector: "#x" }); // ensurePage may fail if no playwright — either blocked missing text first // type checks text before ensurePage... actually type checks text then ensurePage then resolveLocator // missing text → fail before playwright cases.push(["type_missing_text", r.ok === false]); r = await handle({ id: "t4", op: "nope" }); cases.push(["unknown_op", r.ok === false]); r = await handle({ id: "t5", op: "status" }); cases.push(["status", r.ok === true && r.browser_open === false]); const failed = cases.filter(([, okv]) => !okv); const summary = { ok: failed.length === 0, cases: Object.fromEntries(cases), failed: failed.map(([n]) => n), }; console.log(JSON.stringify(summary)); process.exit(failed.length === 0 ? 0 : 1); } async function main() { const args = parseArgs(process.argv); if (args.help) { console.log( JSON.stringify({ ok: true, usage: "browser_helper.mjs [--self-test] | [--cmd JSON] | (JSONL on stdin)", install: INSTALL_HINT, }) ); return; } if (args.selfTest) { await runSelfTest(); return; } if (args.cmd) { let req; try { req = JSON.parse(args.cmd); } catch (e) { console.log(JSON.stringify(fail(null, `invalid --cmd JSON: ${e.message}`))); process.exit(1); return; } const res = await handle(req); console.log(JSON.stringify(res)); process.exit(res.ok ? 0 : 1); return; } // JSONL daemon const rl = createInterface({ input: process.stdin, crlfDelay: Infinity }); for await (const line of rl) { const trimmed = line.trim(); if (!trimmed) continue; let req; try { req = JSON.parse(trimmed); } catch (e) { console.log( JSON.stringify(fail(null, `invalid JSON line: ${e.message}`)) ); continue; } const res = await handle(req); console.log(JSON.stringify(res)); if (req.op === "close" || req.command === "close") { // keep process alive unless parent closes stdin } } if (browser) { await browser.close().catch(() => {}); } } main().catch((e) => { console.error(JSON.stringify(fail(null, e.message || e))); process.exit(1); });