554 lines
18 KiB
Rust
554 lines
18 KiB
Rust
|
|
//! 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);
|
||
|
|
}
|
||
|
|
}
|