2026-09-13 07:57:57 +00:00
|
|
|
#!/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 '<json>'
|
|
|
|
|
* Self-test: node browser_helper.mjs --self-test (no Chromium required)
|
|
|
|
|
*
|
2026-09-13 08:15:27 +00:00
|
|
|
* Ops: ping, navigate, snapshot, click, type, eval, close, status, handoff_prepare
|
2026-09-13 07:57:57 +00:00
|
|
|
* 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;
|
2026-09-13 08:15:27 +00:00
|
|
|
/** Whether the current browser was launched headed (visible). */
|
|
|
|
|
let browserHeaded = false;
|
|
|
|
|
|
|
|
|
|
function envHeadedDefault() {
|
|
|
|
|
const v = (process.env.GROKBOY_BROWSER_HEADED || "").trim().toLowerCase();
|
|
|
|
|
if (v === "1" || v === "true" || v === "yes") return true;
|
|
|
|
|
if (v === "0" || v === "false" || v === "no") return false;
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function wantHeaded(forceHeaded) {
|
|
|
|
|
if (forceHeaded === true) return true;
|
|
|
|
|
return envHeadedDefault();
|
|
|
|
|
}
|
2026-09-13 07:57:57 +00:00
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-13 08:15:27 +00:00
|
|
|
async function launchBrowser(headed) {
|
2026-09-13 07:57:57 +00:00
|
|
|
const { chromium } = await loadPlaywright();
|
|
|
|
|
try {
|
2026-09-13 08:15:27 +00:00
|
|
|
browser = await chromium.launch({ headless: !headed });
|
|
|
|
|
browserHeaded = headed;
|
2026-09-13 07:57:57 +00:00
|
|
|
} 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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-13 08:15:27 +00:00
|
|
|
async function ensurePage(opts = {}) {
|
|
|
|
|
const headed = wantHeaded(opts.headed === true);
|
|
|
|
|
if (page) {
|
|
|
|
|
// Already open; if caller needs headed and we are headless, relaunch below via handoff_prepare.
|
|
|
|
|
return page;
|
|
|
|
|
}
|
|
|
|
|
return launchBrowser(headed);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Close current browser and reopen headed, restoring lastUrl when possible. */
|
|
|
|
|
async function ensureHeadedForHandoff() {
|
|
|
|
|
const restore = lastUrl || (page ? page.url() : null);
|
|
|
|
|
if (browser && browserHeaded && page) {
|
|
|
|
|
try {
|
|
|
|
|
await page.bringToFront();
|
|
|
|
|
} catch {
|
|
|
|
|
/* ignore */
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
prepared: true,
|
|
|
|
|
headed: true,
|
|
|
|
|
relaunched: false,
|
|
|
|
|
url: page.url(),
|
|
|
|
|
restored: false,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (browser) {
|
|
|
|
|
await browser.close().catch(() => {});
|
|
|
|
|
browser = null;
|
|
|
|
|
context = null;
|
|
|
|
|
page = null;
|
|
|
|
|
browserHeaded = false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await launchBrowser(true);
|
|
|
|
|
let restored = false;
|
|
|
|
|
if (restore && restore !== "about:blank") {
|
|
|
|
|
try {
|
|
|
|
|
await page.goto(String(restore), {
|
|
|
|
|
waitUntil: "domcontentloaded",
|
|
|
|
|
timeout: 30000,
|
|
|
|
|
});
|
|
|
|
|
lastUrl = page.url();
|
|
|
|
|
restored = true;
|
|
|
|
|
} catch (e) {
|
|
|
|
|
// Keep headed page even if restore fails; caller still sees visible window.
|
|
|
|
|
lastUrl = page.url();
|
|
|
|
|
return {
|
|
|
|
|
prepared: true,
|
|
|
|
|
headed: true,
|
|
|
|
|
relaunched: true,
|
|
|
|
|
url: lastUrl,
|
|
|
|
|
restored: false,
|
|
|
|
|
restore_error: String(e.message || e),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
await page.bringToFront();
|
|
|
|
|
} catch {
|
|
|
|
|
/* ignore */
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
prepared: true,
|
|
|
|
|
headed: true,
|
|
|
|
|
relaunched: true,
|
|
|
|
|
url: page ? page.url() : null,
|
|
|
|
|
restored,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-13 07:57:57 +00:00
|
|
|
/** 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",
|
2026-09-13 08:15:27 +00:00
|
|
|
"handoff_prepare",
|
2026-09-13 07:57:57 +00:00
|
|
|
],
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
case "status": {
|
|
|
|
|
return ok(id, {
|
|
|
|
|
browser_open: !!browser,
|
2026-09-13 08:15:27 +00:00
|
|
|
headed: browserHeaded,
|
2026-09-13 07:57:57 +00:00
|
|
|
last_url: lastUrl,
|
|
|
|
|
page_url: page ? page.url() : null,
|
2026-09-13 08:15:27 +00:00
|
|
|
env_headed: envHeadedDefault(),
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case "handoff_prepare": {
|
|
|
|
|
// Bring a visible Chromium window forward for human login/OTP/captcha.
|
|
|
|
|
const info = await ensureHeadedForHandoff();
|
|
|
|
|
return ok(id, {
|
|
|
|
|
...info,
|
|
|
|
|
message:
|
|
|
|
|
"Browser is headed (visible). Complete login/OTP/captcha in the window, then resume in the terminal.",
|
2026-09-13 07:57:57 +00:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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": {
|
2026-09-13 08:01:03 +00:00
|
|
|
if (
|
|
|
|
|
!req.selector &&
|
|
|
|
|
!req.role &&
|
|
|
|
|
!req.text &&
|
|
|
|
|
!req.label &&
|
|
|
|
|
!req.placeholder
|
|
|
|
|
) {
|
|
|
|
|
return fail(
|
|
|
|
|
id,
|
|
|
|
|
"click requires selector, role(+name), text, label, or placeholder"
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-09-13 07:57:57 +00:00
|
|
|
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 text = req.text ?? req.value;
|
|
|
|
|
if (text == null) return fail(id, "type: missing text");
|
2026-09-13 08:01:03 +00:00
|
|
|
const p = await ensurePage();
|
2026-09-13 07:57:57 +00:00
|
|
|
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;
|
2026-09-13 08:15:27 +00:00
|
|
|
browserHeaded = false;
|
2026-09-13 07:57:57 +00:00
|
|
|
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]);
|
2026-09-13 08:15:27 +00:00
|
|
|
cases.push([
|
|
|
|
|
"ping_lists_handoff_prepare",
|
|
|
|
|
Array.isArray(r.ops) && r.ops.includes("handoff_prepare"),
|
|
|
|
|
]);
|
2026-09-13 07:57:57 +00:00
|
|
|
|
|
|
|
|
// 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]);
|
|
|
|
|
|
2026-09-13 08:01:03 +00:00
|
|
|
await handle({ id: "t4b", op: "close" });
|
2026-09-13 07:57:57 +00:00
|
|
|
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);
|
|
|
|
|
});
|