LazyBoy2/crates/grokboy-core/src/browser.rs

554 lines
18 KiB
Rust
Raw Normal View History

//! Optional Playwright DOM browser tools (P3).
//!
//! Spawns `tools/playwright/browser_helper.mjs` (JSONL over stdin/stdout).
//! Fail closed with an install hint when Node / Playwright / Chromium is missing.
//! Prefer selectors / roles — not screenshot-first control.
use anyhow::{Context, Result, anyhow};
use serde_json::{Value, json};
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::{Duration, Instant};
pub const INSTALL_HINT: &str = "Playwright browser tools unavailable. Install with: \
cd tools/playwright && npm install && npx playwright install chromium";
const HELPER_REL: &str = "tools/playwright/browser_helper.mjs";
const HELPER_TIMEOUT: Duration = Duration::from_secs(60);
/// Shared last navigated URL for session metadata.
pub type LastUrlSlot = Arc<Mutex<Option<String>>>;
static HELPER: OnceLock<Mutex<HelperState>> = OnceLock::new();
enum HelperState {
/// Not started yet.
Idle,
/// Running JSONL child.
Running {
#[allow(dead_code)]
child: Child,
stdin: ChildStdin,
stdout: BufReader<ChildStdout>,
next_id: u64,
},
/// Permanently unavailable this process (missing node/helper).
Unavailable(String),
}
fn helper_lock() -> &'static Mutex<HelperState> {
HELPER.get_or_init(|| Mutex::new(HelperState::Idle))
}
/// Locate the helper script relative to cwd, then walk parents, then exe-relative.
pub fn find_helper_script(cwd: &Path) -> Option<PathBuf> {
let mut dir = cwd.to_path_buf();
for _ in 0..8 {
let candidate = dir.join(HELPER_REL);
if candidate.is_file() {
return Some(candidate);
}
if !dir.pop() {
break;
}
}
// Also try next to the running binary's ancestors (dev: target/debug).
if let Ok(exe) = std::env::current_exe() {
let mut dir = exe.parent().map(|p| p.to_path_buf());
for _ in 0..6 {
let Some(d) = dir else { break };
let candidate = d.join(HELPER_REL);
if candidate.is_file() {
return Some(candidate);
}
// target/debug -> repo root
if let Some(parent) = d.parent() {
let up2 = parent.parent().map(|p| p.join(HELPER_REL));
if let Some(c) = up2 {
if c.is_file() {
return Some(c);
}
}
}
dir = d.parent().map(|p| p.to_path_buf());
}
}
None
}
fn which_node() -> Option<PathBuf> {
// Prefer PATH lookup.
if let Ok(output) = Command::new("sh")
.arg("-c")
.arg("command -v node")
.output()
{
if output.status.success() {
let p = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !p.is_empty() {
return Some(PathBuf::from(p));
}
}
}
None
}
fn spawn_helper(script: &Path) -> Result<(Child, ChildStdin, BufReader<ChildStdout>)> {
let node = which_node().ok_or_else(|| {
anyhow!("{INSTALL_HINT} (node not found on PATH)")
})?;
let mut child = Command::new(&node)
.arg(script)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.with_context(|| format!("spawn node {}", script.display()))?;
let stdin = child
.stdin
.take()
.ok_or_else(|| anyhow!("helper stdin missing"))?;
let stdout = child
.stdout
.take()
.ok_or_else(|| anyhow!("helper stdout missing"))?;
Ok((child, stdin, BufReader::new(stdout)))
}
fn ensure_running<'a>(state: &'a mut HelperState, cwd: &Path) -> Result<(&'a mut ChildStdin, &'a mut BufReader<ChildStdout>, &'a mut u64)> {
match state {
HelperState::Unavailable(msg) => Err(anyhow!("{msg}")),
HelperState::Running { stdin, stdout, next_id, .. } => Ok((stdin, stdout, next_id)),
HelperState::Idle => {
let script = match find_helper_script(cwd) {
Some(p) => p,
None => {
let msg = format!(
"{INSTALL_HINT} (helper not found at {HELPER_REL} from {})",
cwd.display()
);
*state = HelperState::Unavailable(msg.clone());
return Err(anyhow!("{msg}"));
}
};
match spawn_helper(&script) {
Ok((child, stdin, stdout)) => {
*state = HelperState::Running {
child,
stdin,
stdout,
next_id: 1,
};
match state {
HelperState::Running {
stdin,
stdout,
next_id,
..
} => Ok((stdin, stdout, next_id)),
_ => unreachable!(),
}
}
Err(e) => {
let msg = format!("{INSTALL_HINT} ({e:#})");
*state = HelperState::Unavailable(msg.clone());
Err(anyhow!("{msg}"))
}
}
}
}
}
fn read_json_line(stdout: &mut BufReader<ChildStdout>, deadline: Instant) -> Result<Value> {
let mut line = String::new();
loop {
if Instant::now() > deadline {
return Err(anyhow!("browser helper timed out waiting for response"));
}
// Blocking read — browser ops are infrequent; keep it simple.
line.clear();
let n = stdout
.read_line(&mut line)
.context("read helper stdout")?;
if n == 0 {
return Err(anyhow!("browser helper exited unexpectedly"));
}
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let v: Value = serde_json::from_str(trimmed)
.with_context(|| format!("helper returned non-JSON: {trimmed}"))?;
return Ok(v);
}
}
/// Send one JSON request to the helper; returns the parsed response object.
pub fn browser_request(cwd: &Path, mut req: Value) -> Result<Value> {
let mut guard = helper_lock()
.lock()
.map_err(|_| anyhow!("browser helper lock poisoned"))?;
let (stdin, stdout, next_id) = ensure_running(&mut guard, cwd)?;
let id = *next_id;
*next_id += 1;
if req.get("id").is_none() {
req["id"] = json!(id.to_string());
}
let line = serde_json::to_string(&req)? + "\n";
stdin
.write_all(line.as_bytes())
.context("write to browser helper")?;
stdin.flush().context("flush browser helper")?;
let deadline = Instant::now() + HELPER_TIMEOUT;
let resp = read_json_line(stdout, deadline)?;
// If helper reported permanent missing playwright, mark unavailable for clearer retries.
if resp.get("ok") == Some(&json!(false)) {
if let Some(code) = resp.get("code").and_then(|c| c.as_str()) {
if code == "PLAYWRIGHT_MISSING" || code == "CHROMIUM_MISSING" {
// Keep process; user may install mid-session — don't mark Unavailable.
}
}
}
Ok(resp)
}
/// One-shot `--cmd` invocation (no daemon). Useful for offline protocol tests.
pub fn browser_oneshot(cwd: &Path, req: &Value) -> Result<Value> {
let script = find_helper_script(cwd).ok_or_else(|| {
anyhow!("{INSTALL_HINT} (helper not found)")
})?;
let node = which_node().ok_or_else(|| anyhow!("{INSTALL_HINT} (node not found)"))?;
let cmd_json = serde_json::to_string(req)?;
let output = Command::new(node)
.arg(&script)
.arg("--cmd")
.arg(&cmd_json)
.current_dir(cwd)
.output()
.context("oneshot browser helper")?;
let stdout = String::from_utf8_lossy(&output.stdout);
let line = stdout
.lines()
.rev()
.find(|l| !l.trim().is_empty())
.unwrap_or("");
if line.is_empty() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!(
"browser helper produced no JSON (stderr: {})",
stderr.trim()
));
}
let v: Value = serde_json::from_str(line)
.with_context(|| format!("oneshot non-JSON: {line}"))?;
Ok(v)
}
/// Run helper `--self-test` (no Chromium). Returns parsed summary JSON.
pub fn browser_self_test(cwd: &Path) -> Result<Value> {
let script = find_helper_script(cwd).ok_or_else(|| {
anyhow!("{INSTALL_HINT} (helper not found)")
})?;
let node = which_node().ok_or_else(|| anyhow!("{INSTALL_HINT} (node not found)"))?;
let output = Command::new(node)
.arg(&script)
.arg("--self-test")
.current_dir(
script
.parent()
.unwrap_or(cwd),
)
.output()
.context("browser helper --self-test")?;
let stdout = String::from_utf8_lossy(&output.stdout);
let line = stdout
.lines()
.rev()
.find(|l| !l.trim().is_empty())
.unwrap_or("");
if line.is_empty() {
return Err(anyhow!(
"self-test empty stdout; stderr={}",
String::from_utf8_lossy(&output.stderr)
));
}
let v: Value = serde_json::from_str(line)?;
if !output.status.success() || v.get("ok") != Some(&json!(true)) {
return Err(anyhow!("self-test failed: {v}"));
}
Ok(v)
}
pub fn update_last_url(slot: &LastUrlSlot, resp: &Value) {
if let Some(url) = resp.get("url").and_then(|u| u.as_str()) {
if let Ok(mut g) = slot.lock() {
*g = Some(url.to_string());
}
}
}
pub fn response_to_tool_json(resp: Value) -> Value {
if resp.get("ok") == Some(&json!(true)) {
let mut out = resp;
if let Some(obj) = out.as_object_mut() {
obj.remove("ok");
obj.remove("id");
}
out
} else {
let err = resp
.get("error")
.and_then(|e| e.as_str())
.unwrap_or("browser tool failed");
json!({
"error": err,
"blocked": true,
"install_hint": INSTALL_HINT,
"detail": resp,
})
}
}
/// OpenAI tool definitions for browser ops (always registered; fail closed if missing).
pub fn browser_tool_definitions() -> Vec<Value> {
vec![
json!({
"type": "function",
"function": {
"name": "browser_navigate",
"description": "Navigate the optional Playwright browser to a URL (DOM path). Fails closed with install hint if Playwright is missing.",
"parameters": {
"type": "object",
"properties": {
"url": { "type": "string", "description": "URL to open" }
},
"required": ["url"]
}
}
}),
json!({
"type": "function",
"function": {
"name": "browser_snapshot",
"description": "Structured text snapshot of the page (roles/names/CSS selectors). Prefer this over screenshots for control.",
"parameters": {
"type": "object",
"properties": {},
"additionalProperties": false
}
}
}),
json!({
"type": "function",
"function": {
"name": "browser_click",
"description": "Click an element by CSS selector or role/name (Playwright DOM). Not pixel/XY click.",
"parameters": {
"type": "object",
"properties": {
"selector": { "type": "string", "description": "CSS selector" },
"role": { "type": "string", "description": "ARIA role, e.g. button, link" },
"name": { "type": "string", "description": "Accessible name when using role" },
"text": { "type": "string", "description": "Visible text alternative" },
"label": { "type": "string" },
"placeholder": { "type": "string" }
}
}
}
}),
json!({
"type": "function",
"function": {
"name": "browser_type",
"description": "Type/fill text into an element by CSS selector or role/name.",
"parameters": {
"type": "object",
"properties": {
"text": { "type": "string", "description": "Text to type" },
"selector": { "type": "string" },
"role": { "type": "string" },
"name": { "type": "string" },
"label": { "type": "string" },
"placeholder": { "type": "string" },
"clear": { "type": "boolean", "description": "Fill (clear first) vs append; default true" }
},
"required": ["text"]
}
}
}),
json!({
"type": "function",
"function": {
"name": "browser_eval",
"description": "Evaluate a JavaScript expression in the page context (use sparingly; prefer snapshot/click/type).",
"parameters": {
"type": "object",
"properties": {
"expression": { "type": "string", "description": "JS expression to eval in page" }
},
"required": ["expression"]
}
}
}),
]
}
pub fn is_browser_tool(name: &str) -> bool {
matches!(
name,
"browser_navigate"
| "browser_snapshot"
| "browser_dom"
| "browser_click"
| "browser_type"
| "browser_eval"
)
}
pub async fn execute_browser_tool(
cwd: &Path,
last_url: &LastUrlSlot,
name: &str,
args: &Value,
) -> Result<Value> {
// Run blocking helper I/O off the async runtime.
let cwd = cwd.to_path_buf();
let name = name.to_string();
let args = args.clone();
let last_url = last_url.clone();
tokio::task::spawn_blocking(move || {
let op = match name.as_str() {
"browser_navigate" => "navigate",
"browser_snapshot" | "browser_dom" => "snapshot",
"browser_click" => "click",
"browser_type" => "type",
"browser_eval" => "eval",
other => return Err(anyhow!("unknown browser tool: {other}")),
};
let mut req = args;
if let Some(obj) = req.as_object_mut() {
obj.insert("op".into(), json!(op));
} else {
req = json!({ "op": op });
}
// Normalize eval field
if op == "eval" {
if req.get("expression").is_none() {
if let Some(js) = req.get("js").cloned() {
req["expression"] = js;
}
}
}
let resp = browser_request(&cwd, req)?;
update_last_url(&last_url, &resp);
Ok(response_to_tool_json(resp))
})
.await
.map_err(|e| anyhow!("browser task join: {e}"))?
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
fn repo_cwd() -> PathBuf {
// Crate manifest dir is crates/grokboy-core → repo root is ../..
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.canonicalize()
.unwrap_or_else(|_| PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."))
}
#[test]
fn finds_helper_from_repo_root() {
let root = repo_cwd();
let helper = find_helper_script(&root);
assert!(
helper.is_some(),
"expected helper under {}/{HELPER_REL}",
root.display()
);
}
#[test]
fn helper_self_test_protocol() {
let root = repo_cwd();
// Skip soft if node missing (should still usually be present on Mac).
if which_node().is_none() {
eprintln!("skip: node not on PATH");
return;
}
let summary = browser_self_test(&root).expect("self-test");
assert_eq!(summary["ok"], true);
}
#[test]
fn oneshot_ping_json_protocol() {
let root = repo_cwd();
if which_node().is_none() {
eprintln!("skip: node not on PATH");
return;
}
let resp = browser_oneshot(&root, &json!({"op": "ping", "id": "u1"})).unwrap();
assert_eq!(resp["ok"], true);
assert_eq!(resp["pong"], true);
assert_eq!(resp["protocol"], 1);
}
#[test]
fn response_maps_errors_to_blocked() {
let v = response_to_tool_json(json!({
"ok": false,
"blocked": true,
"error": "nope",
"id": "1"
}));
assert!(v.get("error").is_some());
assert_eq!(v["blocked"], true);
assert!(v["install_hint"].as_str().unwrap().contains("npm install"));
}
#[test]
fn browser_defs_count() {
assert_eq!(browser_tool_definitions().len(), 5);
assert!(is_browser_tool("browser_navigate"));
assert!(is_browser_tool("browser_snapshot"));
assert!(!is_browser_tool("shell"));
}
#[test]
fn install_hint_constant() {
assert!(INSTALL_HINT.contains("npx playwright install chromium"));
}
#[tokio::test]
async fn execute_ping_via_navigate_missing_url_style() {
// Exercise execute path with type missing text → blocked JSON, no chromium needed
// if helper starts. Use browser_type without text through execute_browser_tool args check.
let root = repo_cwd();
if which_node().is_none() || find_helper_script(&root).is_none() {
return;
}
let slot: LastUrlSlot = Arc::new(Mutex::new(None));
// Direct request ping through oneshot already covered; here ensure spawn works with status via request.
let resp = browser_oneshot(&root, &json!({"op":"status"})).unwrap();
assert_eq!(resp["ok"], true);
let _ = slot;
let _ = SystemTime::now().duration_since(UNIX_EPOCH);
}
}