use crate::chrome::contract::{
ChromeResponse, ERROR_PAGE_PROBE_JS, EXPECT_TIMEOUT_NOTE, classify_call_failure, eval_count,
expect_outcome, extract_output, extract_snapshot_text, is_daemon_unavailable_code,
is_daemon_unavailable_error, is_unreachable_tab_error, net_error_phrase,
parse_error_page_probe, sanitize_timeout_message, unreachable_tab_message,
with_condition_timeout_note,
};
use crate::chrome::escape_js_single_quoted;
use crate::chrome::forms::{
ExpectCond, ExtractGate, count_eval_js, expect_args, extract_gate, parse_count_op,
parse_predicate, parse_state, validate_extract_getters, wait_args, wait_target,
};
use crate::chrome::spawn::{CliRun, CliSpawn, CliTimeout, spawn_cli};
use crate::util::{UnwrapPoison, is_http_url};
use crate::{Tool, Workspace};
use anyhow::Context;
use async_trait::async_trait;
use futures_util::future::join_all;
use serde::Deserialize;
use serde_json::{Value, json};
use std::collections::{BTreeSet, HashMap};
use std::hash::{DefaultHasher, Hash, Hasher};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use tracing::debug;
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "snake_case")]
enum ChromeAction {
Open { url: String },
Snapshot {
#[serde(default)]
interactive_only: bool,
#[serde(default = "true_val")]
compact: bool,
depth: Option<u32>,
},
Click { selector: String },
GetText { selector: String },
#[serde(alias = "get_innertext", alias = "innertext")]
GetInnerText { selector: String },
GetUrl {},
Press {
key: String,
#[serde(default)]
selector: Option<String>,
},
Fill { selector: String, text: String },
Type {
selector: String,
text: String,
#[serde(default)]
key_events: bool,
},
Eval { js: String },
Wait {
#[serde(default)]
selector: Option<String>,
#[serde(default)]
url: Option<String>,
#[serde(default)]
text: Option<String>,
},
Expect {
condition: String,
#[serde(default)]
selector: Option<String>,
#[serde(default)]
op: Option<String>,
#[serde(default)]
count: Option<u64>,
#[serde(default)]
predicate: Option<String>,
#[serde(default)]
name: Option<String>,
#[serde(default)]
expected: Option<String>,
},
Extract {
schema: Value,
#[serde(default)]
limit: Option<usize>,
},
Find {
by: String,
value: String,
action: String,
text: Option<String>,
name: Option<String>,
exact: Option<bool>,
index: Option<u32>,
},
Screenshot {},
}
const fn true_val() -> bool {
true
}
const AGENT_TAB_PREFIX: &str = "agent-tab-";
const DEFAULT_TAB: &str = "default";
static CHROME_BOOT_SALT: std::sync::LazyLock<String> =
std::sync::LazyLock::new(crate::generate_suffix);
static CHROME_NAMESPACE_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
#[derive(Default)]
pub(crate) struct ChromeRunSessions(std::sync::Mutex<BTreeSet<String>>);
impl ChromeRunSessions {
fn track(&self, name: &str) {
self.0.lock().unwrap_poison().insert(name.to_string());
}
pub(crate) fn snapshot(&self) -> Vec<String> {
self.0.lock().unwrap_poison().iter().cloned().collect()
}
}
#[derive(Default)]
pub struct ChromeTool {
tab_locks: std::sync::Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
last_screenshot: std::sync::Mutex<Option<String>>,
session_prefix: String,
chrome_sessions: std::sync::Arc<ChromeRunSessions>,
}
impl ChromeTool {
pub(crate) fn new(chrome_sessions: std::sync::Arc<ChromeRunSessions>) -> Self {
let id = CHROME_NAMESPACE_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
Self {
session_prefix: format!("{AGENT_TAB_PREFIX}{}-{id}-", *CHROME_BOOT_SALT),
chrome_sessions,
..Default::default()
}
}
fn resolve_session(&self, tab: &str) -> String {
if self.session_prefix.is_empty() || tab.starts_with(&self.session_prefix) {
return tab.to_string();
}
let clean = sanitize_filename_component(tab);
if clean == tab {
return format!("{}{clean}", self.session_prefix);
}
let mut hasher = DefaultHasher::new();
tab.hash(&mut hasher);
format!(
"{}{clean}-{:08x}",
self.session_prefix,
hasher.finish() & 0xffff_ffff
)
}
async fn acquire_tab_lock(&self, tab: &str) -> tokio::sync::OwnedMutexGuard<()> {
let lock = {
let mut locks = self.tab_locks.lock().unwrap_poison();
locks
.entry(tab.to_string())
.or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
.clone()
};
lock.lock_owned().await
}
pub async fn fetch_page_text(&self, url: &str, tab: &str) -> anyhow::Result<String> {
crate::chrome::validate_url(url)?;
Self::ensure_available().await?;
let _guard = self.acquire_tab_lock(tab).await;
let opened = self.run_command(&["open", url], tab).await?;
self.bail_on_failed_navigation(tab, url, &opened).await?;
let _ = self
.run_command(&["wait", "--load", "networkidle"], tab)
.await;
let text = self.get_inner_text("body", tab).await?;
Ok(text)
}
pub async fn close_session(&self, tab: &str) {
super::chrome_daemon::sweep_session(tab).await;
}
async fn bail_on_failed_navigation(
&self,
tab: &str,
url: &str,
response: &ChromeResponse,
) -> anyhow::Result<()> {
let Some(committed_url) = response
.data
.as_ref()
.and_then(|d| d.get("url"))
.and_then(Value::as_str)
else {
return Ok(());
};
if crate::chrome::is_chrome_error_page(committed_url) {
let code = self
.run_command(&["eval", ERROR_PAGE_PROBE_JS], tab)
.await
.ok()
.and_then(|r| parse_error_page_probe(&r))
.filter(|p| p.is_error_page)
.and_then(|p| p.code);
match code {
Some(code) => anyhow::bail!(
"Navigation to {url} failed — Chrome rendered its error page: {code} ({}). \
Verify the URL and network.",
net_error_phrase(&code)
),
None => anyhow::bail!(
"Navigation to {url} failed — Chrome landed on its error page \
(chrome-error://chromewebdata/), meaning the site is unreachable (DNS failure, \
refused connection, or a blocked/unsafe port). Verify the URL and network."
),
}
}
if crate::chrome::is_blank_page_url(committed_url) {
self.close_session(tab).await;
anyhow::bail!(
"Navigation failed: the tab is still on a blank page after opening {url} — the \
page never loaded. This usually means the site is unreachable or blocks \
automated navigation, or the chrome-use extension relay is down."
);
}
Ok(())
}
async fn ensure_available() -> anyhow::Result<()> {
match super::chrome_daemon::cli_probe().await {
super::chrome_daemon::CliStatus::Available => {}
super::chrome_daemon::CliStatus::Missing => {
anyhow::bail!(
"chrome-use CLI is not available. {}",
super::chrome_daemon::CHROME_USE_INSTALL_HINT
);
}
super::chrome_daemon::CliStatus::Transient(failure) => {
let msg = match failure {
super::chrome_daemon::CliProbeFailure::Spawn(reason) => format!(
"chrome-use CLI check could not spawn the binary ({reason}) — a \
temporary failure (e.g. system resource exhaustion), not a missing \
install. Retry shortly."
),
super::chrome_daemon::CliProbeFailure::BadVersion(status) => format!(
"chrome-use CLI is installed but its `--version` check failed \
({status}) — the install looks broken. chrome-use self-updates \
once per boot, which only replaces the binary when a newer \
release exists; check the logs for the last update error, or \
reinstall manually via the chrome-use CLI if you want \
hands-on control."
),
super::chrome_daemon::CliProbeFailure::Timeout => {
"chrome-use CLI probe timed out — the binary is present but \
unresponsive. Retry shortly; if this persists the CLI may be wedged."
.to_string()
}
};
anyhow::bail!(msg);
}
}
if !super::chrome_daemon::is_available().await {
anyhow::bail!("{}", super::chrome_daemon::daemon_down_message());
}
Ok(())
}
const WAIT_DEADLINE: Duration = Duration::from_secs(10);
const EXPECT_DEADLINE: Duration = Duration::from_secs(20);
fn call_timeout(args: &[&str]) -> Duration {
use crate::chrome::{DEADLINE_SLACK, DEFAULT_OPEN_TIMEOUT};
match args.first() {
Some(&"open") => DEFAULT_OPEN_TIMEOUT + DEADLINE_SLACK,
Some(&"wait") => Self::WAIT_DEADLINE + DEADLINE_SLACK,
Some(&"expect") => Self::EXPECT_DEADLINE + DEADLINE_SLACK,
_ => Duration::from_secs(8),
}
}
async fn run_command(&self, args: &[&str], tab: &str) -> anyhow::Result<ChromeResponse> {
let cli = super::chrome_daemon::cli_path().with_context(|| {
format!(
"chrome-use CLI is not available. {}",
super::chrome_daemon::CHROME_USE_INSTALL_HINT
)
})?;
let session = self.resolve_session(tab);
self.chrome_sessions.track(&session);
let mut logged_args: Vec<&str> = args.to_vec();
logged_args.extend(["--json", "--session", &session]);
debug!("chrome-use args: {:?}", logged_args);
let bound = Self::call_timeout(args);
let chrome_deadline =
(args.first() == Some(&"open")).then_some(crate::chrome::DEFAULT_OPEN_TIMEOUT);
let run = spawn_cli(CliSpawn {
path: &cli,
args,
session: Some(&session),
json: true,
capture_stderr: true,
timeout: CliTimeout::Bounded(bound),
cancel_kills: true,
input: None,
chrome_deadline,
})
.await;
let output = match run {
CliRun::Output(output) => output,
CliRun::SpawnFailure => anyhow::bail!("Failed to execute chrome-use CLI"),
CliRun::TimedOut => {
if let Some(down_message) =
super::chrome_daemon::health_after_call_timeout(&session).await
{
anyhow::bail!("{down_message}");
}
let secs = Self::call_timeout(args).as_secs();
anyhow::bail!(
"chrome-use did not answer within {secs}s for `{}` — the call was aborted. \
The daemon looked healthy, so this is usually a slow page or a wedged CLI; \
retry once before trying another approach.",
args.join(" ")
);
}
};
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
match parse_run_output(
args.first().copied(),
output.status.success(),
&stdout,
&stderr,
) {
ParsedRun::Ok(resp) => Ok(resp),
ParsedRun::Unparseable => {
anyhow::bail!("Failed to parse chrome-use JSON response")
}
ParsedRun::Failed {
error,
code,
retryable,
} => {
let error_msg = if error.is_empty() {
format!("chrome-use exited with code {}", output.status)
} else {
enhance_chrome_error(error)
};
let kind = classify_call_failure(code.as_deref(), &error_msg);
let error_msg = sanitize_timeout_message(kind, &error_msg);
Self::fail_fast_if_daemon_down(&error_msg, code.as_deref())?;
let mut error_msg = with_retry_hint(error_msg, retryable);
with_condition_timeout_note(
args.first().copied().unwrap_or(""),
kind,
&mut error_msg,
);
anyhow::bail!("{}: {error_msg}", kind.as_str());
}
}
}
fn fail_fast_if_daemon_down(error: &str, code: Option<&str>) -> anyhow::Result<()> {
if is_unreachable_tab_error(error) {
anyhow::bail!("{}", unreachable_tab_message(error));
}
if is_daemon_unavailable_error(error) || is_daemon_unavailable_code(code) {
super::chrome_daemon::note_unhealthy(error);
anyhow::bail!("{}", super::chrome_daemon::daemon_down_message());
}
Ok(())
}
async fn get_inner_text(&self, selector: &str, tab: &str) -> anyhow::Result<String> {
const FALLBACK_NOTE: &str =
"(used get text fallback — textContent, may include script/style text)";
let js = inner_text_eval_js(selector);
if let Ok(resp) = self.run_command(&["eval", &js], tab).await
&& let Some(data) = resp.data.as_ref()
&& let Some(text) = extract_snapshot_text(data)
&& !text.trim().is_empty()
{
return Ok(text);
}
let resp = self.run_command(&["get", "text", selector], tab).await?;
let mut text = resp
.data
.as_ref()
.and_then(extract_snapshot_text)
.unwrap_or_default();
if !text.is_empty() {
text.push('\n');
text.push_str(FALLBACK_NOTE);
}
Ok(text)
}
#[expect(clippy::too_many_lines)] fn build_args(action: &ChromeAction) -> anyhow::Result<Vec<String>> {
match action {
ChromeAction::Open { url } => {
crate::chrome::validate_url(url)?;
Ok(vec!["open".into(), url.clone()])
}
ChromeAction::Snapshot {
interactive_only,
compact,
depth,
} => {
let mut args = vec!["snapshot".into()];
if *interactive_only {
args.push("-i".into());
}
if *compact {
args.push("-c".into());
}
if let Some(d) = depth {
args.push("-d".into());
args.push(d.to_string());
}
Ok(args)
}
ChromeAction::Click { selector } => Ok(vec!["click".into(), selector.clone()]),
ChromeAction::GetText { selector } => {
Ok(vec!["get".into(), "text".into(), selector.clone()])
}
ChromeAction::GetInnerText { .. } => {
anyhow::bail!("GetInnerText is handled in execute(), not build_args")
}
ChromeAction::GetUrl { .. } => Ok(vec!["get".into(), "url".into()]),
ChromeAction::Press { key, selector } => {
let mut args = vec!["press".into(), key.clone()];
if let Some(sel) = selector {
args.extend(["--selector".into(), sel.clone()]);
}
Ok(args)
}
ChromeAction::Fill { selector, text } => {
let mut args = vec!["fill".into(), selector.clone()];
args.extend(crate::chrome::forms::text_value_argv(text));
Ok(args)
}
ChromeAction::Type {
selector,
text,
key_events,
} => {
let mut args = vec!["type".into(), selector.clone()];
if *key_events {
args.push("--key-events".into());
}
args.extend(crate::chrome::forms::text_value_argv(text));
Ok(args)
}
ChromeAction::Eval { js } => Ok(vec!["eval".into(), js.clone()]),
ChromeAction::Find {
by,
value,
action,
text,
name,
exact,
index,
} => {
let mut args = vec!["find".into(), by.clone()];
if by == "nth" {
let idx = index.map_or_else(|| "0".into(), |i| i.to_string());
args.push(idx);
}
args.push(value.clone());
args.push(action.clone());
if let Some(t) = text {
args.push(t.clone());
}
if let Some(n) = name {
args.push("--name".into());
args.push(n.clone());
}
if *exact == Some(true) {
args.push("--exact".into());
}
Ok(args)
}
ChromeAction::Screenshot { .. } => {
anyhow::bail!("Screenshot is handled in execute(), not build_args")
}
ChromeAction::Wait {
selector,
url,
text,
} => {
let target = wait_target(selector.as_deref(), url.as_deref(), text.as_deref())
.map_err(anyhow::Error::msg)?;
Ok(wait_args(&target, Self::WAIT_DEADLINE.as_millis()))
}
ChromeAction::Expect {
condition,
selector,
op,
count,
predicate,
name,
expected,
} => {
let cond = Self::build_expect_cond(
condition,
selector.as_ref(),
op.as_ref(),
count.as_ref(),
predicate.as_ref(),
name.as_ref(),
expected.as_ref(),
)?;
Ok(expect_args(&cond, Self::EXPECT_DEADLINE.as_millis()))
}
ChromeAction::Extract { .. } => {
anyhow::bail!("Extract is handled in execute(), not build_args")
}
}
}
fn build_expect_cond(
condition: &str,
selector: Option<&String>,
op: Option<&String>,
count: Option<&u64>,
predicate: Option<&String>,
name: Option<&String>,
expected: Option<&String>,
) -> anyhow::Result<ExpectCond> {
let require_selector = |what: &str| {
selector.cloned().ok_or_else(|| {
anyhow::anyhow!("expect condition \"{condition}\" requires \"selector\" ({what})")
})
};
let require_predicate = || {
parse_predicate(predicate.map_or("equals", String::as_str)).ok_or_else(|| {
anyhow::anyhow!(
"invalid predicate '{predicate:?}' — use equals | contains | matches"
)
})
};
Ok(match condition {
"visible" | "hidden" | "present" => ExpectCond::State {
selector: require_selector("a CSS selector to check")?,
state: parse_state(condition).expect("condition matched the state allowlist"),
},
"count" => ExpectCond::Count {
selector: require_selector("the CSS selector to count")?,
op: parse_count_op(op.map_or("==", String::as_str)).ok_or_else(|| {
anyhow::anyhow!("invalid count op '{op:?}' — use == != > < >= <=")
})?,
n: count.copied().ok_or_else(|| {
anyhow::anyhow!(
"expect condition \"count\" requires \"count\" (the expected number)"
)
})?,
},
"text" | "value" => {
let predicate = require_predicate()?;
let selector = require_selector("the element to read")?;
let value = expected.cloned().ok_or_else(|| {
anyhow::anyhow!("expect condition \"{condition}\" requires \"expected\"")
})?;
if condition == "text" {
ExpectCond::Text {
selector,
predicate,
value,
}
} else {
ExpectCond::Value {
selector,
predicate,
value,
}
}
}
"attr" => {
let predicate = require_predicate()?;
ExpectCond::Attr {
selector: require_selector("the element holding the attribute")?,
name: name.cloned().ok_or_else(|| {
anyhow::anyhow!(
"expect condition \"attr\" requires \"name\" (the attribute name)"
)
})?,
predicate,
value: expected.cloned().ok_or_else(|| {
anyhow::anyhow!("expect condition \"attr\" requires \"expected\"")
})?,
}
}
"url" => {
let predicate = require_predicate()?;
ExpectCond::Url {
predicate,
pattern: expected.cloned().ok_or_else(|| {
anyhow::anyhow!(
"expect condition \"url\" requires \"expected\" (the URL pattern)"
)
})?,
}
}
other => anyhow::bail!(
"invalid expect condition '{other}' — use visible | hidden | present | count | text | value | attr | url"
),
})
}
}
pub async fn close_all_chrome_sessions() {
if tokio::time::timeout(SHUTDOWN_CLEANUP_TIMEOUT, close_all_chrome_sessions_inner())
.await
.is_err()
{
tracing::warn!("chrome-use session cleanup timed out — daemon wedged, skipping");
}
}
const SHUTDOWN_CLEANUP_TIMEOUT: Duration = Duration::from_secs(10);
fn parse_session_list(v: &Value) -> Vec<String> {
let array = v
.get("sessions")
.or_else(|| v.get("data").and_then(|d| d.get("sessions")))
.and_then(Value::as_array);
array
.map(|arr| {
arr.iter()
.filter_map(|entry| match entry {
Value::String(s) => Some(s.clone()),
Value::Object(_) => {
entry.get("name").and_then(|v| v.as_str()).map(String::from)
}
_ => None,
})
.collect()
})
.unwrap_or_default()
}
async fn close_all_chrome_sessions_inner() {
let Some(cmd) = super::chrome_daemon::cli_path() else {
tracing::debug!("chrome-use not available, skipping chrome cleanup");
return;
};
let list_output = match spawn_cli(CliSpawn {
path: &cmd,
args: &["session", "list"],
session: None,
json: true,
capture_stderr: true,
timeout: CliTimeout::Unbounded,
cancel_kills: true,
input: None,
chrome_deadline: None,
})
.await
{
CliRun::Output(o) => o,
CliRun::SpawnFailure => {
tracing::debug!("chrome-use not available, skipping chrome cleanup: spawn failed");
return;
}
CliRun::TimedOut => {
tracing::debug!("chrome-use session list timed out, skipping chrome cleanup");
return;
}
};
let sessions: Vec<String> = match serde_json::from_slice::<Value>(&list_output.stdout) {
Ok(v) => {
if ChromeResponse::from_value(&v).verdict() == Some(false) {
tracing::warn!(
"chrome-use session list failed: {}",
v.get("error")
.and_then(|e| e.as_str())
.unwrap_or("unknown error")
);
return;
}
parse_session_list(&v)
}
Err(e) => {
tracing::warn!("failed to parse chrome-use session list output: {e}");
return;
}
};
if sessions.is_empty() {
tracing::debug!("No open chrome-use sessions to close");
return;
}
let close_futures: Vec<_> = sessions
.iter()
.map(|session_id| {
let cmd = &cmd;
async move {
match spawn_cli(CliSpawn {
path: cmd.as_path(),
args: &["--session", session_id, "close"],
session: None,
json: false,
capture_stderr: false,
cancel_kills: true,
timeout: CliTimeout::Unbounded,
input: None,
chrome_deadline: None,
})
.await
{
CliRun::Output(out) if out.status.success() => {
tracing::debug!("Closed chrome-use session: {session_id}");
}
CliRun::Output(out) => {
tracing::warn!(
"chrome-use close session '{session_id}' exited with status: {}",
out.status
);
}
CliRun::SpawnFailure => {
tracing::warn!(
"failed to close chrome-use session '{session_id}': spawn failed"
);
}
CliRun::TimedOut => {
tracing::warn!(
"failed to close chrome-use session '{session_id}': timed out"
);
}
}
}
})
.collect();
join_all(close_futures).await;
}
#[async_trait]
impl Tool for ChromeTool {
fn name(&self) -> &'static str {
"chrome"
}
fn is_advertised(&self) -> bool {
super::chrome_daemon::is_advertised()
}
fn parameters_schema(&self) -> Value {
let entries: Vec<Value> = crate::chrome::actions::ACTIONS
.iter()
.filter_map(|a| {
a.tool.as_ref().map(|t| {
super::action_entry_schema(a.name, a.purpose, t.required, &t.properties)
})
})
.collect();
json!({
"type": "object",
"properties": {
"action": { "oneOf": entries },
"tab": {
"type": "string",
"description": "Logical name for this chrome session. \
Missing or empty defaults to a unique per-run session \
(closed automatically when your run ends). Sessions are \
mapped to a private per-run namespace under the hood, so \
the same name never collides across concurrent runs. Only \
use an explicit name (e.g. \"docs\", \"github\") if you \
need to keep multiple pages open simultaneously. Same \
tab = serialized operations on that page."
}
},
"required": ["action", "tab"]
})
}
async fn execute(&self, _ws: &Workspace, args: Value) -> anyhow::Result<String> {
let (tab, action, normalized_notes) = Self::normalize_call(&args)?;
debug!(tab, action = ?action, "chrome action");
Self::ensure_available().await?;
Self::validate_find(&action)?;
let _guard = self.acquire_tab_lock(&tab).await;
if let ChromeAction::GetInnerText { selector } = &action {
let output = self.get_inner_text(selector, &tab).await?;
let body = if output.is_empty() {
format!("[Tab: {tab}] (no output)")
} else {
format!("[Tab: {tab}] {output}")
};
return Ok(super::with_normalization_notes(body, &normalized_notes));
}
if let ChromeAction::Screenshot { .. } = &action {
let output = self.capture_screenshot(&tab).await?;
return Ok(super::with_normalization_notes(output, &normalized_notes));
}
if let ChromeAction::Extract { schema, limit } = &action {
let (schema, schema_note) = Self::normalize_extract_schema(schema)?;
let output = self.run_extract(&schema, *limit, &tab).await?;
let mut notes = normalized_notes;
notes.extend(schema_note);
return Ok(super::with_normalization_notes(output, ¬es));
}
let (response, snapshot) = self.run_action(&action, &tab).await?;
Ok(Self::format_action_output(
&action,
&tab,
response,
&snapshot,
&normalized_notes,
))
}
async fn image_payload(
&self,
_ws: &Workspace,
args: &serde_json::Value,
) -> Option<crate::tools::ImagePayload> {
let action_value = args.get("action")?.clone();
let (action_value, _) = normalize_action(action_value, args).ok()?;
let action: ChromeAction = serde_json::from_value(action_value).ok()?;
if !matches!(action, ChromeAction::Screenshot { .. }) {
return None;
}
let path = self.last_screenshot.lock().unwrap_poison().clone()?;
let p = PathBuf::from(&path);
let meta = crate::util::local_image_to_compressed_data_uri_with_meta(&p)
.await
.ok()?;
Some(crate::tools::ImagePayload::from_compressed_meta(
&p,
meta,
None,
crate::tools::ImagePayloadSource::Chrome,
))
}
}
impl ChromeTool {
fn normalize_tab(args: &Value) -> (String, Option<String>) {
if let Some(tab) = super::get_opt_str(args, "tab").filter(|s| !s.is_empty()) {
(tab.to_string(), None)
} else {
let note = format!("missing/empty tab defaulted to \"{DEFAULT_TAB}\"");
(DEFAULT_TAB.to_string(), Some(note))
}
}
async fn capture_screenshot(&self, tab: &str) -> anyhow::Result<String> {
let path = Self::screenshot_output_path(tab)?;
let path_str = path.to_string_lossy().into_owned();
let str_args = ["screenshot", path_str.as_str()];
let _response = self.run_command(&str_args, tab).await?;
if !path.is_file() {
anyhow::bail!(
"Chrome screenshot reported success but no PNG was written at {}",
path.display()
);
}
let (width, height) = validate_png(&path)?;
*self.last_screenshot.lock().unwrap_poison() = Some(path_str.clone());
Ok(format!(
"[Tab: {tab}] Captured a chrome screenshot: {path_str} ({width}x{height}). \
[IMAGE:{path_str}]"
))
}
fn screenshot_output_path(tab: &str) -> anyhow::Result<PathBuf> {
let dir = std::env::temp_dir().join("chrome-screenshots");
std::fs::create_dir_all(&dir).with_context(|| {
format!(
"Failed to create chrome screenshot directory {}",
dir.display()
)
})?;
let slug = sanitize_filename_component(tab);
let nonce = rand::random::<u64>();
Ok(dir.join(format!("{slug}_{nonce:016x}.png")))
}
fn normalize_call(args: &Value) -> anyhow::Result<(String, ChromeAction, Vec<String>)> {
let mut normalized_notes: Vec<String> = Vec::new();
let (tab, tab_note) = Self::normalize_tab(args);
if let Some(note) = tab_note {
normalized_notes.push(note);
}
let action_value = args
.get("action")
.cloned()
.ok_or_else(|| anyhow::anyhow!(corrective_action_error(args)))?;
let (action_value, normalized_note) =
normalize_action(action_value, args).map_err(|e| anyhow::anyhow!(e))?;
if let Some(note) = normalized_note {
normalized_notes.push(format!("action normalized: {action_value} ({note})"));
}
let action: ChromeAction = serde_json::from_value(action_value.clone()).map_err(|e| {
let hint = match &action_value {
Value::Object(map) if map.contains_key("find") => {
" 'find' requires 'by', 'value', and 'action' fields (use 'value' not 'name' for the locator text). Valid 'action' values: click, fill, hover, check, text (use 'text' param only for fill)".to_string()
}
_ => String::new(),
};
anyhow::anyhow!(
"Invalid chrome action arguments{hint}. Expected action to be {EXPECTED_ACTION_SHAPE}, \
plus a \"tab\" string. Serde error: {e}"
)
})?;
Ok((tab, action, normalized_notes))
}
fn validate_find(action: &ChromeAction) -> anyhow::Result<()> {
if let ChromeAction::Find {
by,
action: find_action,
name,
exact,
index,
..
} = action
{
let valid = [
"role",
"text",
"label",
"placeholder",
"alt",
"title",
"testid",
"first",
"last",
"nth",
];
if !valid.contains(&by.as_str()) {
anyhow::bail!(
"Invalid 'find' locator type '{by}'. Must be one of: {}",
valid.join(", ")
);
}
let valid_actions = ["click", "hover", "fill", "check", "text"];
if !valid_actions.contains(&find_action.as_str()) {
anyhow::bail!(
"Invalid 'find' action '{find_action}'. Must be one of: {}",
valid_actions.join(", ")
);
}
if by == "nth" && index.is_none() {
anyhow::bail!(
"'index' is required when 'by' is \"nth\". \
Provide the zero-based index of the element to select."
);
}
if (name.is_some() || *exact == Some(true))
&& matches!(by.as_str(), "nth" | "first" | "last" | "testid")
{
anyhow::bail!(
"`name`/`exact` are not honored for the '{by}' locator — chrome-use's parser \
treats everything after the action as fill text, so they would be consumed \
as the fill value. Drop `name`/`exact` when using '{by}' (they only apply \
to role/text-style locators) and match by `value` instead."
);
}
}
Ok(())
}
async fn run_action(
&self,
action: &ChromeAction,
tab: &str,
) -> anyhow::Result<(ChromeResponse, String)> {
let cli_args = Self::build_args(action)?;
let str_args: Vec<&str> = cli_args.iter().map(String::as_str).collect();
let response = self.run_command(&str_args, tab).await?;
if let ChromeAction::Open { url } = action {
self.bail_on_failed_navigation(tab, url, &response).await?;
}
let snapshot = if matches!(action, ChromeAction::Open { .. }) {
let wait_args = ["wait", "--load", "networkidle"];
let _ = self.run_command(&wait_args, tab).await;
let snap = self
.run_command(&["snapshot", "-c"], tab)
.await
.ok()
.and_then(|r| r.data.as_ref().and_then(extract_snapshot_text))
.unwrap_or_default();
if snap.trim().is_empty() {
self.get_inner_text("body", tab).await.unwrap_or_default()
} else {
snap
}
} else {
String::new()
};
Ok((response, snapshot))
}
fn format_action_output(
action: &ChromeAction,
tab: &str,
response: ChromeResponse,
snapshot: &str,
notes: &[String],
) -> String {
use std::fmt::Write as _;
let warning = if matches!(
action,
ChromeAction::Fill { .. } | ChromeAction::Type { .. } | ChromeAction::Press { .. }
) {
crate::chrome::contract::chrome_use_warning(&response)
} else {
None
};
let output = match response.data {
Some(data) => match action {
ChromeAction::Snapshot { .. } | ChromeAction::GetText { .. } => {
extract_snapshot_text(&data)
.or_else(|| serde_json::to_string_pretty(&data).ok())
.unwrap_or_default()
}
ChromeAction::Open { .. } => {
let mut s = format!(
"Opened {}",
data.get("url").and_then(|v| v.as_str()).unwrap_or("?")
);
if snapshot.trim().is_empty() {
s.push_str("\n\n(no page content captured)");
} else {
let _ = write!(s, "\n\n--- Page content ---\n{snapshot}");
}
s
}
ChromeAction::GetUrl { .. } => data
.get("url")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string(),
ChromeAction::Eval { .. } => data.get("result").map_or_else(
|| serde_json::to_string_pretty(&data).unwrap_or_else(|_| data.to_string()),
|v| v.as_str().map_or_else(|| v.to_string(), str::to_string),
),
ChromeAction::Wait { .. } => "wait complete".to_string(),
ChromeAction::Expect { .. } => match expect_outcome(&data) {
Some(outcome) if outcome.pass => "expect: PASS (condition holds)".to_string(),
Some(outcome) => {
let actual = outcome.actual.map_or("n/a".to_string(), |v| v.to_string());
let mut s = format!(
"expect: FAIL — condition did not hold (timed out: {}); actual: {actual}",
outcome.timed_out
);
if outcome.timed_out {
s.push_str(EXPECT_TIMEOUT_NOTE);
}
s
}
None => {
serde_json::to_string_pretty(&data).unwrap_or_else(|_| data.to_string())
}
},
_ => serde_json::to_string_pretty(&data).unwrap_or_else(|_| data.to_string()),
},
None => String::new(),
};
let mut output = if output.is_empty() {
format!("[Tab: {tab}] (no output)")
} else {
format!("[Tab: {tab}] {output}")
};
if let Some(warning) = warning {
let text = warning
.as_str()
.map_or_else(|| warning.to_string(), str::to_string);
let _ = write!(
output,
"\n\n⚠ chrome-use warning: {text} — treat the action as NOT applied and choose another approach"
);
}
super::with_normalization_notes(output, notes)
}
fn normalize_extract_schema(schema: &Value) -> anyhow::Result<(Value, Option<String>)> {
let parsed = match schema {
Value::Object(_) => schema.clone(),
Value::String(s) => serde_json::from_str::<Value>(s).map_err(|_| {
anyhow::anyhow!(
"extract schema string is not valid JSON — pass the schema as a JSON object"
)
})?,
_ => anyhow::bail!(
"extract schema must be a JSON object with a \"fields\" object (field name → CSS selector)"
),
};
let Some(map) = parsed.as_object() else {
anyhow::bail!(
"extract schema must be a JSON object with a \"fields\" object (field name → CSS selector)"
)
};
let field_shape = "field name → CSS selector string or {sel, get, all} object";
if let Some(rows) = map.get("rows") {
anyhow::ensure!(
rows.is_string(),
"extract schema \"rows\" must be a CSS selector string"
);
}
let (out, note) = if let Some(fields) = map.get("fields") {
anyhow::ensure!(
fields.is_object(),
"extract schema \"fields\" must be an object ({field_shape})"
);
(parsed, None)
} else {
let mut fields = serde_json::Map::new();
for (k, v) in map.iter().filter(|(k, _)| k.as_str() != "rows") {
anyhow::ensure!(
v.is_string() || v.is_object(),
"extract schema field '{k}' must be a {field_shape} — \
chrome-use requires a \"fields\" object"
);
fields.insert(k.clone(), v.clone());
}
let mut out = serde_json::Map::new();
if let Some(r) = map.get("rows") {
out.insert("rows".into(), r.clone());
}
out.insert("fields".into(), Value::Object(fields));
(
Value::Object(out),
Some(
"flat extract schema normalized: field keys moved under \"fields\" \
(chrome-use requires a fields object)"
.to_string(),
),
)
};
validate_extract_getters(&out).map_err(anyhow::Error::msg)?;
Ok((out, note))
}
async fn run_extract(
&self,
schema: &Value,
limit: Option<usize>,
tab: &str,
) -> anyhow::Result<String> {
let rows_sel = schema.get("rows").and_then(Value::as_str);
if let Some(sel) = rows_sel {
let js = count_eval_js(sel);
let resp = self.run_command(&["eval", &js], tab).await?;
let count = eval_count(&resp).ok_or_else(|| {
anyhow::anyhow!(
"count eval for the rows selector '{sel}' returned a non-numeric result"
)
});
match extract_gate(count) {
ExtractGate::Empty => {
return serde_json::to_string_pretty(&extract_output(
&Value::Array(vec![]),
limit,
))
.context("serialize extract output");
}
ExtractGate::Proceed => {}
ExtractGate::Fail(e) => return Err(e),
}
}
let schema_str = serde_json::to_string(schema)?;
let resp = self
.run_command(&["extract", "--schema", &schema_str], tab)
.await?;
let Some(data) = resp.data.as_ref() else {
anyhow::bail!("extract returned no data");
};
serde_json::to_string_pretty(&extract_output(data, limit))
.context("serialize extract output")
}
}
fn validate_png(path: &Path) -> anyhow::Result<(u32, u32)> {
const PNG_MAGIC: [u8; 8] = [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
let mut header = [0u8; 24];
let mut file = std::fs::File::open(path)
.with_context(|| format!("Failed to read screenshot {}", path.display()))?;
std::io::Read::read_exact(&mut file, &mut header).map_err(|_| {
anyhow::anyhow!(
"Screenshot at {} is not a valid PNG (truncated or wrong format)",
path.display()
)
})?;
if header[..8] != PNG_MAGIC {
anyhow::bail!(
"Screenshot at {} is not a valid PNG (wrong format)",
path.display()
);
}
let width = u32::from_be_bytes(header[16..20].try_into().expect("slice is 4 bytes"));
let height = u32::from_be_bytes(header[20..24].try_into().expect("slice is 4 bytes"));
if width == 0 || height == 0 {
anyhow::bail!(
"Screenshot at {} has an empty IHDR ({}x{})",
path.display(),
width,
height
);
}
Ok((width, height))
}
fn sanitize_filename_component(name: &str) -> String {
let mut out: String = name
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect();
if out.is_empty() {
out.push_str("default");
}
if out.len() > 40 {
out.truncate(40);
}
out
}
#[derive(Debug)]
enum ParsedRun {
Ok(ChromeResponse),
Failed {
error: String,
code: Option<String>,
retryable: Option<bool>,
},
Unparseable,
}
fn parse_run_output(
action: Option<&str>,
status_success: bool,
stdout: &str,
stderr: &str,
) -> ParsedRun {
let parsed: Option<ChromeResponse> = serde_json::from_str(stdout).ok();
let Some(resp) = parsed else {
return if status_success {
ParsedRun::Unparseable
} else {
ParsedRun::Failed {
error: stderr.trim().to_string(),
code: None,
retryable: None,
}
};
};
if resp.is_success() && (status_success || action == Some("expect")) {
return ParsedRun::Ok(resp);
}
ParsedRun::Failed {
error: resp.error.unwrap_or_default(),
code: resp.code,
retryable: resp.retryable,
}
}
fn enhance_chrome_error(msg: String) -> String {
let lower = msg.to_ascii_lowercase();
if lower.contains("unknown ref")
|| lower.contains("node with given id does not belong to the document")
{
format!(
"{msg}. Hint: refs become stale after any navigation or DOM change. \
Take a fresh snapshot before using refs again."
)
} else {
msg
}
}
fn with_retry_hint(error: String, retryable: Option<bool>) -> String {
if retryable == Some(true) {
format!("{error} (chrome-use marked this error retryable — a retry may succeed)")
} else {
error
}
}
const KNOWN_ACTIONS: &[&str] = &[
"open",
"snapshot",
"click",
"get_text",
"get_inner_text",
"get_innertext",
"innertext",
"get_url",
"press",
"fill",
"type",
"eval",
"find",
"screenshot",
"wait",
"expect",
"extract",
];
const EXPECTED_ACTION_SHAPE: &str = "one of: {\"open\":{\"url\":\"https://...\"}}, \
{\"snapshot\":{\"interactive_only\":bool,\"compact\":bool,\"depth\":int}}, \
{\"click\":{\"selector\":\"...\"}}, {\"get_text\":{\"selector\":\"...\"}}, \
{\"get_innertext\":{\"selector\":\"...\"}}, {\"get_url\":{}}, \
{\"press\":{\"key\":\"...\",\"selector\":\"...\"}}, \
{\"fill\":{\"selector\":\"...\",\"text\":\"...\"}}, \
{\"type\":{\"selector\":\"...\",\"text\":\"...\",\"key_events\":bool}}, \
{\"eval\":{\"js\":\"...\"}}, \
{\"find\":{\"by\":\"text|role|label|placeholder|alt|title|testid|first|last|nth\",\
\"value\":\"...\",\"action\":\"click|fill|hover|check|text\"}}, \
{\"screenshot\":{}}, {\"wait\":{\"selector\":\"...\"}}, \
{\"expect\":{\"condition\":\"visible|hidden|present|count|text|value|attr|url\",\
\"selector\":\"...\",\"count\":int,\"op\":\"==\",\"predicate\":\"equals|contains|matches\",\"name\":\"...\",\"expected\":\"...\"}}, \
{\"extract\":{\"schema\":{...},\"limit\":int}}";
fn corrective_action_error(received: &Value) -> String {
format!(
"Invalid chrome action arguments. Expected action to be {EXPECTED_ACTION_SHAPE}, \
plus a \"tab\" string. Received: {received}"
)
}
fn normalize_action(action: Value, args: &Value) -> Result<(Value, Option<String>), String> {
match action {
Value::Object(map) if map.len() == 1 => {
let (name, inner) = map.into_iter().next().expect("len == 1");
if !KNOWN_ACTIONS.contains(&name.as_str()) {
return Err(corrective_action_error(&json!({name: inner})));
}
match inner {
Value::String(s) if name == "open" => Ok((
json!({"open": {"url": s}}),
Some("bare-string value for open action treated as url".to_string()),
)),
Value::Object(o) => {
require_find_action(&name, &o)?;
Ok((json!({name: o}), None))
}
_ => Err(corrective_action_error(&json!({name: inner}))),
}
}
Value::String(s) => {
let s = s.trim();
if s.starts_with('{') {
return parse_embedded_json(s, args, "stringified JSON");
}
if let Some(inner) = s
.strip_prefix("<![CDATA[")
.and_then(|r| r.strip_suffix("]]>"))
{
return parse_embedded_json(inner.trim(), args, "CDATA-wrapped JSON");
}
if s.starts_with('<') {
if let Some((v, note)) = parse_xml_action(s) {
return Ok((v, Some(note.to_string())));
}
return Err(corrective_action_error(&Value::String(s.to_string())));
}
if is_http_url(s) {
return Ok((
json!({"open": {"url": s}}),
Some("bare URL treated as open action".to_string()),
));
}
if KNOWN_ACTIONS.contains(&s) {
return build_action_from_siblings(s, args);
}
Err(corrective_action_error(&Value::String(s.to_string())))
}
other => Err(corrective_action_error(&other)),
}
}
fn parse_embedded_json(
s: &str,
args: &Value,
wrapper: &str,
) -> Result<(Value, Option<String>), String> {
let parsed: Value = serde_json::from_str(s)
.map_err(|_| corrective_action_error(&Value::String(s.to_string())))?;
let (normalized, note) = normalize_action(parsed, args)?;
Ok((
normalized,
Some(match note {
Some(inner) => format!("{wrapper} action; {inner}"),
None => format!("{wrapper} action parsed"),
}),
))
}
fn require_find_action(name: &str, o: &serde_json::Map<String, Value>) -> Result<(), String> {
if name == "find" && !o.contains_key("action") {
return Err(corrective_action_error(&json!({name: o})));
}
Ok(())
}
fn build_action_from_siblings(name: &str, args: &Value) -> Result<(Value, Option<String>), String> {
let Some(obj) = args.as_object() else {
return Err(corrective_action_error(args));
};
let mut siblings = serde_json::Map::new();
for (k, v) in obj {
if k == "action" || k == "tab" {
continue;
}
siblings.insert(k.clone(), v.clone());
}
if let Some(payload) = siblings.remove(name) {
let note = format!("sibling '{name}' object used as action payload");
match payload {
Value::Object(o) => {
require_find_action(name, &o)?;
Ok((json!({name: o}), Some(note)))
}
Value::String(s) if name == "open" => Ok((json!({"open": {"url": s}}), Some(note))),
Value::String(s) if name == "find" => {
let parsed: Value = serde_json::from_str(&s)
.map_err(|_| corrective_action_error(&Value::String(s.clone())))?;
let Value::Object(o) = parsed else {
return Err(corrective_action_error(&Value::String(s)));
};
require_find_action(name, &o)?;
Ok((json!({"find": o}), Some(note)))
}
_ => Err(corrective_action_error(&Value::Object(siblings))),
}
} else {
match name {
"open" => {
let url = siblings
.remove("url")
.ok_or_else(|| corrective_action_error(&Value::Object(siblings.clone())))?;
Ok((
json!({"open": {"url": url}}),
Some("flattened 'url' wrapped into open action".to_string()),
))
}
"find" => {
if !siblings.contains_key("action") {
return Err(corrective_action_error(&Value::Object(siblings)));
}
Ok((
json!({"find": siblings}),
Some("flattened find fields wrapped into find action".to_string()),
))
}
_ => Ok((
json!({name: siblings}),
Some(format!("flattened fields wrapped into {name} action")),
)),
}
}
}
fn parse_xml_action(s: &str) -> Option<(Value, &'static str)> {
let s = s.trim();
let s = s.strip_suffix("</action>").map_or(s, str::trim);
if let Some(inner) = s
.strip_prefix("<open>")
.and_then(|r| r.strip_suffix("</open>"))
{
let inner = inner.trim();
if inner.is_empty() {
return None; }
if let Some(url) = inner
.strip_prefix("<url>")
.and_then(|r| r.strip_suffix("</url>"))
{
return Some((
json!({"open": {"url": url.trim()}}),
"XML <open><url>…</url></open>",
));
}
return Some((json!({"open": {"url": inner}}), "XML <open>URL</open>"));
}
if let Some(inner) = s
.strip_prefix("<find>")
.and_then(|r| r.strip_suffix("</find>"))
{
let mut fields = serde_json::Map::new();
for (tag, key) in [
("by", "by"),
("value", "value"),
("action", "action"),
("text", "text"),
("name", "name"),
("exact", "exact"),
("index", "index"),
] {
if let Some(v) = extract_xml_field(inner, tag) {
fields.insert(key.to_string(), json!(v));
}
}
if let Some(v) = fields
.get("exact")
.and_then(|v| v.as_str())
.and_then(|v| v.parse::<bool>().ok())
{
fields.insert("exact".to_string(), json!(v));
}
if let Some(v) = fields
.get("index")
.and_then(|v| v.as_str())
.and_then(|v| v.parse::<u32>().ok())
{
fields.insert("index".to_string(), json!(v));
}
if !fields.is_empty() {
return Some((json!({"find": fields}), "XML <find>…</find>"));
}
}
if let Some(rest) = s.strip_prefix("<parameter name=\"open\"") {
let content = rest.split_once('>').map_or(rest, |(_, r)| r).trim();
if content.starts_with('{') {
if let Ok(parsed) = serde_json::from_str::<Value>(content) {
return Some((
json!({"open": parsed}),
"XML <parameter name=\"open\"> JSON",
));
}
} else if let Some(url) = content
.strip_prefix("<parameter name=\"url\"")
.and_then(|r| r.split_once('>').map(|(_, r)| r))
{
let url = url.trim().trim_end_matches("</parameter>").trim();
if !url.is_empty() {
return Some((
json!({"open": {"url": url}}),
"XML <parameter name=\"open\"> URL",
));
}
} else if !content.is_empty() {
return Some((
json!({"open": {"url": content}}),
"XML <parameter name=\"open\"> URL",
));
}
}
None
}
fn extract_xml_field(inner: &str, tag: &str) -> Option<String> {
let open = format!("<{tag}>");
let close = format!("</{tag}>");
let start = inner.find(&open)? + open.len();
let end = inner[start..].find(&close)? + start;
let v = inner[start..end].trim();
if v.is_empty() {
None
} else {
Some(v.to_string())
}
}
fn inner_text_eval_js(selector: &str) -> String {
let escaped = escape_js_single_quoted(selector);
format!(
"(() => {{ const el = document.querySelector('{escaped}'); return el ? el.innerText : ''; }})()"
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::chrome_daemon::chrome_bin;
#[test]
fn build_args_for_simple_actions() {
struct Case {
name: &'static str,
action: ChromeAction,
expected: &'static [&'static str],
}
let cases = [
Case {
name: "open",
action: ChromeAction::Open {
url: "https://example.com".into(),
},
expected: &["open", "https://example.com"],
},
Case {
name: "snapshot",
action: ChromeAction::Snapshot {
interactive_only: true,
compact: true,
depth: Some(5),
},
expected: &["snapshot", "-i", "-c", "-d", "5"],
},
Case {
name: "click",
action: ChromeAction::Click {
selector: "@e1".into(),
},
expected: &["click", "@e1"],
},
Case {
name: "get_text",
action: ChromeAction::GetText {
selector: "@e3".into(),
},
expected: &["get", "text", "@e3"],
},
Case {
name: "get_url",
action: ChromeAction::GetUrl {},
expected: &["get", "url"],
},
Case {
name: "fill",
action: ChromeAction::Fill {
selector: "#e".into(),
text: "hi".into(),
},
expected: &["fill", "#e", "hi"],
},
Case {
name: "fill leading-dash text",
action: ChromeAction::Fill {
selector: "#e".into(),
text: "--foo".into(),
},
expected: &["fill", "#e", "--", "--foo"],
},
Case {
name: "type",
action: ChromeAction::Type {
selector: "#e".into(),
text: "hi".into(),
key_events: true,
},
expected: &["type", "#e", "--key-events", "hi"],
},
Case {
name: "type leading-dash text",
action: ChromeAction::Type {
selector: "#e".into(),
text: "--foo".into(),
key_events: true,
},
expected: &["type", "#e", "--key-events", "--", "--foo"],
},
Case {
name: "press no selector",
action: ChromeAction::Press {
key: "Enter".into(),
selector: None,
},
expected: &["press", "Enter"],
},
Case {
name: "press with selector",
action: ChromeAction::Press {
key: "Enter".into(),
selector: Some("#t".into()),
},
expected: &["press", "Enter", "--selector", "#t"],
},
];
for case in &cases {
let args = ChromeTool::build_args(&case.action).unwrap_or_else(|e| {
panic!("{}: build_args failed: {}", case.name, e);
});
assert_eq!(args, case.expected, "{}", case.name);
}
}
#[test]
fn build_args_rejects_get_innertext() {
let action = ChromeAction::GetInnerText {
selector: "body".into(),
};
assert!(
ChromeTool::build_args(&action).is_err(),
"GetInnerText must be handled in execute(), not build_args"
);
}
#[test]
fn inner_text_eval_js_escapes_quotes() {
let js = inner_text_eval_js("it's");
assert!(js.contains("it\\'s"));
assert!(!js.contains("innertext"));
}
#[test]
fn inner_text_eval_js_body_and_ref() {
let body = inner_text_eval_js("body");
assert!(body.contains("document.querySelector('body')"));
assert!(body.contains("innerText"));
let refr = inner_text_eval_js("@e1");
assert!(refr.contains("document.querySelector('@e1')"));
}
#[expect(clippy::too_many_lines)]
#[test]
fn build_args_for_find_variants() {
struct Case {
name: &'static str,
by: &'static str,
value: &'static str,
action: &'static str,
text: Option<&'static str>,
find_name: Option<&'static str>,
exact: Option<bool>,
index: Option<u32>,
expected: &'static [&'static str],
}
let cases = [
Case {
name: "find_by_text_click",
by: "text",
value: "Sign In",
action: "click",
text: None,
find_name: None,
exact: None,
index: None,
expected: &["find", "text", "Sign In", "click"],
},
Case {
name: "find_by_text_fill",
by: "text",
value: "Search",
action: "fill",
text: Some("tokio"),
find_name: None,
exact: None,
index: None,
expected: &["find", "text", "Search", "fill", "tokio"],
},
Case {
name: "find_by_first",
by: "first",
value: "a",
action: "fill",
text: None,
find_name: None,
exact: None,
index: None,
expected: &["find", "first", "a", "fill"],
},
Case {
name: "find_by_nth_with_index",
by: "nth",
value: ".card",
action: "hover",
text: None,
find_name: None,
exact: None,
index: Some(2),
expected: &["find", "nth", "2", ".card", "hover"],
},
Case {
name: "find_by_nth_default_index",
by: "nth",
value: "a",
action: "click",
text: None,
find_name: None,
exact: None,
index: None,
expected: &["find", "nth", "0", "a", "click"],
},
Case {
name: "find_with_name_and_exact",
by: "role",
value: "button",
action: "click",
text: None,
find_name: Some("Submit"),
exact: Some(true),
index: None,
expected: &[
"find", "role", "button", "click", "--name", "Submit", "--exact",
],
},
Case {
name: "find_with_name_only",
by: "role",
value: "link",
action: "click",
text: None,
find_name: Some("Docs.rs"),
exact: None,
index: None,
expected: &["find", "role", "link", "click", "--name", "Docs.rs"],
},
Case {
name: "find_check",
by: "text",
value: "Accept",
action: "check",
text: None,
find_name: None,
exact: None,
index: None,
expected: &["find", "text", "Accept", "check"],
},
Case {
name: "find_text_action",
by: "first",
value: ".result",
action: "text",
text: None,
find_name: None,
exact: None,
index: None,
expected: &["find", "first", ".result", "text"],
},
];
for case in &cases {
let action = ChromeAction::Find {
by: case.by.into(),
value: case.value.into(),
action: case.action.into(),
text: case.text.map(String::from),
name: case.find_name.map(String::from),
exact: case.exact,
index: case.index,
};
let args = ChromeTool::build_args(&action).unwrap_or_else(|e| {
panic!("{}: build_args failed: {}", case.name, e);
});
assert_eq!(args, case.expected, "{}", case.name);
}
}
#[test]
fn validate_find_rejects_runtime_unsupported_actions() {
for bad_action in ["focus", "type", "uncheck"] {
let action = ChromeAction::Find {
by: "text".into(),
value: "anything".into(),
action: bad_action.into(),
text: None,
name: None,
exact: None,
index: None,
};
let err = ChromeTool::validate_find(&action)
.expect_err("focus/type/uncheck must be rejected")
.to_string();
assert!(
err.contains("Invalid 'find' action"),
"action {bad_action}: {err}"
);
}
}
#[test]
fn validate_find_rejects_name_exact_on_css_locators() {
let find = |by: &str, name: Option<&str>, exact: Option<bool>| ChromeAction::Find {
by: by.into(),
value: ".card".into(),
action: "click".into(),
text: None,
name: name.map(String::from),
exact,
index: if by == "nth" { Some(0) } else { None },
};
for by in ["nth", "first", "last", "testid"] {
let err = ChromeTool::validate_find(&find(by, Some("Submit"), None))
.expect_err("name with CSS-selector locator must be rejected")
.to_string();
assert!(
err.contains("`name`/`exact`") && err.contains(by),
"by {by}: {err}"
);
let err = ChromeTool::validate_find(&find(by, None, Some(true)))
.expect_err("exact with CSS-selector locator must be rejected")
.to_string();
assert!(
err.contains("`name`/`exact`") && err.contains(by),
"by {by}: {err}"
);
}
ChromeTool::validate_find(&find("role", Some("Submit"), Some(false)))
.expect("role + name/exact must still validate");
}
#[test]
fn call_timeout_policy() {
assert_eq!(
ChromeTool::call_timeout(&["open", "https://example.com"]),
Duration::from_secs(22)
);
assert_eq!(
ChromeTool::call_timeout(&["wait", "--load", "networkidle"]),
Duration::from_secs(12)
);
assert_eq!(
ChromeTool::call_timeout(&["expect", "visible", "#x"]),
Duration::from_secs(22)
);
for args in [
&["click", "@e1"][..],
&["eval", "1+1"][..],
&["snapshot", "-c"][..],
] {
assert_eq!(
ChromeTool::call_timeout(args),
Duration::from_secs(8),
"args: {args:?}"
);
}
}
#[test]
fn tool_name_and_description_are_set() {
let tool = ChromeTool::default();
assert_eq!(tool.name(), "chrome");
assert!(!tool.description().is_empty());
}
#[test]
fn parameters_schema_is_valid_json() {
let tool = ChromeTool::default();
let schema = tool.parameters_schema();
assert!(schema.is_object());
assert!(
schema
.get("properties")
.and_then(|p| p.get("action"))
.is_some()
);
}
#[test]
fn parameters_schema_has_all_actions() {
let tool = ChromeTool::default();
let schema = tool.parameters_schema();
let action_schemas = schema["properties"]["action"]["oneOf"]
.as_array()
.expect("oneOf should be an array");
assert_eq!(
action_schemas.len(),
15,
"expected 15 actions, got {}",
action_schemas.len()
);
let action_names: Vec<&str> = action_schemas
.iter()
.filter_map(|s| {
s.get("properties")
.and_then(|p| p.as_object())
.and_then(|props| props.keys().next())
.map(String::as_str)
})
.collect();
for expected in &[
"open",
"snapshot",
"click",
"get_text",
"get_innertext",
"get_url",
"press",
"fill",
"type",
"eval",
"find",
"screenshot",
"wait",
"expect",
"extract",
] {
assert!(
action_names.contains(expected),
"schema missing action: {expected}"
);
}
for s in action_schemas {
let inner = s
.get("properties")
.and_then(|p| p.as_object())
.and_then(|props| props.values().next())
.and_then(|v| v.as_object());
let name = s
.get("properties")
.and_then(|p| p.as_object())
.and_then(|props| props.keys().next())
.map_or("?", String::as_str);
let has_inner_required = inner.is_some_and(|obj| obj.contains_key("required"));
if name == "snapshot" || name == "get_url" || name == "screenshot" || name == "wait" {
assert!(
!has_inner_required,
"{name} should NOT have inner 'required'"
);
} else {
assert!(has_inner_required, "{name} should have inner 'required'");
}
}
}
#[test]
fn chrome_bin_name_is_correct() {
let name = chrome_bin();
if cfg!(target_os = "windows") {
assert_eq!(name, "chrome-use.exe");
} else {
assert_eq!(name, "chrome-use");
}
}
#[tokio::test]
async fn fetch_page_text_propagates_url_validation_errors() {
let tool = ChromeTool::default();
let err = tool.fetch_page_text("", "test-tab").await.unwrap_err();
assert!(
err.to_string().contains("cannot be empty"),
"expected empty-url error, got: {err}",
);
let err = tool
.fetch_page_text("file:///etc/passwd", "test-tab")
.await
.unwrap_err();
assert!(
err.to_string().contains("not allowed"),
"expected file:// rejection, got: {err}",
);
let err = tool
.fetch_page_text("ftp://example.com", "test-tab")
.await
.unwrap_err();
assert!(
err.to_string().contains("Only http:// and https://"),
"expected scheme rejection, got: {err}",
);
}
fn assert_normalizes(
action: serde_json::Value,
args: &serde_json::Value,
expected: &serde_json::Value,
) {
let (normalized, note) = normalize_action(action, args).unwrap_or_else(|e| {
panic!("expected normalization to succeed, got: {e}");
});
assert_eq!(normalized, *expected, "normalized action mismatch");
assert!(
note.is_some(),
"recoverable shape should echo a normalization note"
);
}
fn assert_rejects(action: serde_json::Value, args: &serde_json::Value) {
let err = normalize_action(action, args).unwrap_err();
assert!(
err.contains("Expected action to be"),
"expected corrective shape error, got: {err}"
);
}
#[test]
fn normalize_flat_action_with_sibling_fields() {
assert_normalizes(
json!("open"),
&json!({"action":"open","url":"https://example.com","tab":"t"}),
&json!({"open":{"url":"https://example.com"}}),
);
assert_normalizes(
json!("open"),
&json!({"action":"open","open":{"url":"https://example.com"},"tab":"t"}),
&json!({"open":{"url":"https://example.com"}}),
);
assert_normalizes(
json!("open"),
&json!({"action":"open","open":"https://example.com","tab":"t"}),
&json!({"open":{"url":"https://example.com"}}),
);
assert_normalizes(
json!("snapshot"),
&json!({"action":"snapshot","tab":"t"}),
&json!({"snapshot":{}}),
);
assert_normalizes(
json!("get_text"),
&json!({"action":"get_text","selector":"body","tab":"t"}),
&json!({"get_text":{"selector":"body"}}),
);
}
#[test]
fn normalize_stringified_json_action() {
assert_normalizes(
json!("{\"open\": {\"url\": \"https://example.com\"}}"),
&json!({"action":"{\"open\": {\"url\": \"https://example.com\"}}","tab":"t"}),
&json!({"open":{"url":"https://example.com"}}),
);
assert_normalizes(
json!("<![CDATA[{\"open\": {\"url\": \"https://example.com\"}}]]>"),
&json!({"action":"<![CDATA[{\"open\": {\"url\": \"https://example.com\"}}]]>","tab":"t"}),
&json!({"open":{"url":"https://example.com"}}),
);
}
#[test]
fn normalize_bare_open_value() {
assert_normalizes(
json!({"open":"https://example.com"}),
&json!({"action":{"open":"https://example.com"},"tab":"t"}),
&json!({"open":{"url":"https://example.com"}}),
);
assert_normalizes(
json!("https://example.com"),
&json!({"action":"https://example.com","tab":"t"}),
&json!({"open":{"url":"https://example.com"}}),
);
}
#[test]
fn normalize_xml_wrapped_actions() {
assert_normalizes(
json!("<open><url>https://example.com</url></open>"),
&json!({"action":"<open><url>https://example.com</url></open>","tab":"t"}),
&json!({"open":{"url":"https://example.com"}}),
);
assert_normalizes(
json!("<open>https://example.com</open>"),
&json!({"action":"<open>https://example.com</open>","tab":"t"}),
&json!({"open":{"url":"https://example.com"}}),
);
assert_normalizes(
json!("<open>\n<url>https://example.com</url>\n</open>"),
&json!({"action":"<open>\n<url>https://example.com</url>\n</open>","tab":"t"}),
&json!({"open":{"url":"https://example.com"}}),
);
assert_normalizes(
json!("<open><url>https://example.com</url></open></action>"),
&json!({"action":"<open><url>https://example.com</url></open></action>","tab":"t"}),
&json!({"open":{"url":"https://example.com"}}),
);
assert_normalizes(
json!("<find><by>first</by><value>a</value><action>click</action></find>"),
&json!({"action":"<find><by>first</by><value>a</value><action>click</action></find>","tab":"t"}),
&json!({"find":{"by":"first","value":"a","action":"click"}}),
);
assert_normalizes(
json!(
"<find><by>text</by><value>submit</value><action>click</action><exact>true</exact></find>"
),
&json!({"action":"<find><by>text</by><value>submit</value><action>click</action><exact>true</exact></find>","tab":"t"}),
&json!({"find":{"by":"text","value":"submit","action":"click","exact":true}}),
);
assert_normalizes(
json!(
"<find><by>nth</by><value>input</value><action>click</action><index>2</index></find>"
),
&json!({"action":"<find><by>nth</by><value>input</value><action>click</action><index>2</index></find>","tab":"t"}),
&json!({"find":{"by":"nth","value":"input","action":"click","index":2}}),
);
}
#[test]
fn missing_or_empty_tab_defaults_to_logical_default() {
let (tab, note) =
ChromeTool::normalize_tab(&json!({"action":{"open":{"url":"https://example.com"}}}));
assert_eq!(tab, DEFAULT_TAB);
assert!(note.is_some(), "defaulting should be echoed in tool output");
let (tab2, note) =
ChromeTool::normalize_tab(&json!({"tab":"","action":{"open":{"url":"x"}}}));
assert_eq!(tab, tab2);
assert!(note.is_some());
let (tab, note) =
ChromeTool::normalize_tab(&json!({"tab":"docs","action":{"open":{"url":"x"}}}));
assert_eq!(tab, "docs");
assert!(note.is_none());
}
#[test]
fn session_resolution_namespaces_per_agent_run() {
let a = ChromeTool::new(Arc::new(ChromeRunSessions::default()));
let b = ChromeTool::new(Arc::new(ChromeRunSessions::default()));
for tab in [DEFAULT_TAB, "docs"] {
let (sa, sb) = (a.resolve_session(tab), b.resolve_session(tab));
assert_ne!(sa, sb, "each run needs its own session for tab {tab}");
assert!(sa.starts_with(AGENT_TAB_PREFIX) && sb.starts_with(AGENT_TAB_PREFIX));
assert!(sa.ends_with(tab) && sb.ends_with(tab));
}
let physical = a.resolve_session("docs");
assert_eq!(a.resolve_session(&physical), physical);
let other_physical = b.resolve_session("docs");
assert_ne!(a.resolve_session(&other_physical), other_physical);
assert_eq!(
ChromeTool::default().resolve_session("link-enricher-3"),
"link-enricher-3"
);
let s = a.resolve_session("my tab/../x");
assert!(
s.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'),
"physical session must be charset-safe, got {s}"
);
assert_ne!(a.resolve_session("a b"), a.resolve_session("a/b"));
}
#[test]
fn agent_sessions_are_never_orphan_swept() {
let tool = ChromeTool::new(Arc::new(ChromeRunSessions::default()));
for tab in [DEFAULT_TAB, "docs"] {
assert!(!crate::tools::chrome_daemon::is_mahbot_session_name(
&tool.resolve_session(tab)
));
}
}
#[test]
fn normalize_rejects_unrecoverable_shapes() {
assert_rejects(json!(null), &json!({}));
assert_rejects(
json!({"expand":15}),
&json!({"action":{"expand":15},"tab":"t"}),
);
assert_rejects(
json!({"__raw":"{\"open\":{...}}"}),
&json!({"action":{"__raw":"x"},"tab":"t"}),
);
assert_rejects(json!("navigate"), &json!({"action":"navigate","tab":"t"}));
assert_rejects(
json!({"find":{"by":"text","value":"x"}}),
&json!({"action":{"find":{"by":"text","value":"x"}},"tab":"t"}),
);
assert_rejects(json!("open"), &json!({"action":"open","tab":"t"}));
}
#[tokio::test]
async fn fail_fast_returns_guidance_without_retrying() {
let _guard = crate::tools::chrome_daemon::with_health_test_lock().await;
crate::tools::chrome_daemon::reset_health();
let err = ChromeTool::fail_fast_if_daemon_down(
"Auto-launch failed: Could not drive your Chrome through the ab-connect extension. \
The tab this session was driving can no longer be resolved (it was closed, or a \
flaky relay dropped it)",
Some("browser_not_launched"),
)
.unwrap_err();
assert!(
err.to_string().contains("close the leftover tab in Chrome"),
"expected hand-close guidance, got: {err}"
);
assert!(crate::tools::chrome_daemon::is_advertised());
let err = ChromeTool::fail_fast_if_daemon_down(
"Failed to read: Resource temporarily unavailable (os error 35) (after 5 retries - daemon may be busy or unresponsive)",
None,
)
.unwrap_err();
assert!(
err.to_string().contains("chrome daemon is down"),
"expected guidance message, got: {err}"
);
assert!(!crate::tools::chrome_daemon::is_advertised());
let err = ChromeTool::fail_fast_if_daemon_down(
"chrome-use error: browser not launched",
Some("browser_not_launched"),
)
.unwrap_err();
assert!(
err.to_string().contains("chrome daemon is down"),
"expected guidance message, got: {err}"
);
assert!(
ChromeTool::fail_fast_if_daemon_down(
"chrome-use error: Navigation failed: net::ERR_CONNECTION_REFUSED",
Some("connection_failed"),
)
.is_ok()
);
assert!(
ChromeTool::fail_fast_if_daemon_down("chrome-use error: Element not found", None)
.is_ok()
);
assert!(
ChromeTool::fail_fast_if_daemon_down("chrome-use error: timed out", Some("timeout"))
.is_ok()
);
crate::tools::chrome_daemon::reset_health();
}
#[test]
fn known_actions_lockstep_with_chrome_action_variants() {
let payload = |name: &str| -> Value {
match name {
"open" => json!({"url": "https://example.com"}),
"snapshot" | "get_url" | "screenshot" => json!({}),
"click" => json!({"selector": "@e1"}),
"get_text" => json!({"selector": "body"}),
"get_inner_text" | "get_innertext" | "innertext" => {
json!({"selector": "body"})
}
"press" => json!({"key": "Enter", "selector": "#t"}),
"fill" => json!({"selector": "#e", "text": "hi"}),
"type" => json!({"selector": "#e", "text": "hi", "key_events": true}),
"eval" => json!({"js": "1 + 1"}),
"find" => json!({"by": "text", "value": "x", "action": "click"}),
"wait" => json!({"selector": "#x"}),
"expect" => json!({"condition": "visible", "selector": "#x"}),
"extract" => json!({"schema": {"rows": ".r"}}),
other => panic!("KNOWN_ACTIONS entry {other} has no lockstep payload"),
}
};
for name in KNOWN_ACTIONS {
let tagged = json!({*name: payload(name)});
assert!(
serde_json::from_value::<ChromeAction>(tagged.clone()).is_ok(),
"KNOWN_ACTIONS entry does not deserialize: {tagged}"
);
}
for name in [
"open",
"snapshot",
"click",
"get_text",
"get_inner_text",
"get_url",
"press",
"fill",
"type",
"eval",
"find",
"screenshot",
"wait",
"expect",
"extract",
] {
assert!(
KNOWN_ACTIONS.contains(&name),
"KNOWN_ACTIONS is missing variant {name}"
);
}
}
#[test]
fn build_args_rejects_screenshot() {
let action = ChromeAction::Screenshot {};
assert!(
ChromeTool::build_args(&action).is_err(),
"Screenshot must be handled in execute(), not build_args"
);
}
#[expect(clippy::too_many_lines)] #[test]
fn build_args_for_wait_and_expect() {
fn argv(args: &[String]) -> Vec<&str> {
args.iter().map(String::as_str).collect()
}
let wait_selector = ChromeAction::Wait {
selector: Some("#r".into()),
url: None,
text: None,
};
let got = ChromeTool::build_args(&wait_selector).unwrap();
assert_eq!(argv(&got), vec!["wait", "#r", "--timeout", "10000"]);
let wait_url = ChromeAction::Wait {
selector: None,
url: Some("dashboard".into()),
text: None,
};
let got = ChromeTool::build_args(&wait_url).unwrap();
assert_eq!(
argv(&got),
vec!["wait", "--url", "dashboard", "--timeout", "10000"]
);
let wait_text = ChromeAction::Wait {
selector: None,
url: None,
text: Some("Loaded".into()),
};
let got = ChromeTool::build_args(&wait_text).unwrap();
assert_eq!(
argv(&got),
vec!["wait", "--text", "Loaded", "--timeout", "10000"]
);
let both = ChromeAction::Wait {
selector: Some("#r".into()),
url: Some("dashboard".into()),
text: None,
};
assert!(
ChromeTool::build_args(&both).is_err(),
"two targets must err"
);
let none = ChromeAction::Wait {
selector: None,
url: None,
text: None,
};
assert!(ChromeTool::build_args(&none).is_err(), "no target must err");
let numeric = ChromeAction::Wait {
selector: Some("5000".into()),
url: None,
text: None,
};
let err = ChromeTool::build_args(&numeric).unwrap_err().to_string();
assert!(err.contains("silent sleep"), "err: {err}");
let expect_visible = ChromeAction::Expect {
condition: "visible".into(),
selector: Some("#main".into()),
op: None,
count: None,
predicate: None,
name: None,
expected: None,
};
let got = ChromeTool::build_args(&expect_visible).unwrap();
assert_eq!(
argv(&got),
vec!["expect", "#main", "visible", "--timeout", "20000"]
);
let expect_count_default = ChromeAction::Expect {
condition: "count".into(),
selector: Some(".card".into()),
op: None,
count: Some(5),
predicate: None,
name: None,
expected: None,
};
let got = ChromeTool::build_args(&expect_count_default).unwrap();
assert_eq!(
argv(&got),
vec!["expect", "count", ".card", "==", "5", "--timeout", "20000"]
);
let expect_count_op = ChromeAction::Expect {
condition: "count".into(),
selector: Some(".card".into()),
op: Some(">=".into()),
count: Some(5),
predicate: None,
name: None,
expected: None,
};
let got = ChromeTool::build_args(&expect_count_op).unwrap();
assert_eq!(
argv(&got),
vec!["expect", "count", ".card", ">=", "5", "--timeout", "20000"]
);
let expect_url = ChromeAction::Expect {
condition: "url".into(),
selector: None,
op: None,
count: None,
predicate: Some("contains".into()),
name: None,
expected: Some("dashboard".into()),
};
let got = ChromeTool::build_args(&expect_url).unwrap();
assert_eq!(
argv(&got),
vec![
"expect",
"url",
"contains",
"dashboard",
"--timeout",
"20000"
]
);
let bad_condition = ChromeAction::Expect {
condition: "banana".into(),
selector: None,
op: None,
count: None,
predicate: None,
name: None,
expected: None,
};
assert!(ChromeTool::build_args(&bad_condition).is_err());
let count_no_count = ChromeAction::Expect {
condition: "count".into(),
selector: Some(".card".into()),
op: None,
count: None,
predicate: None,
name: None,
expected: None,
};
assert!(ChromeTool::build_args(&count_no_count).is_err());
let visible_no_selector = ChromeAction::Expect {
condition: "visible".into(),
selector: None,
op: None,
count: None,
predicate: None,
name: None,
expected: None,
};
assert!(ChromeTool::build_args(&visible_no_selector).is_err());
let bad_op = ChromeAction::Expect {
condition: "count".into(),
selector: Some(".card".into()),
op: Some("!%".into()),
count: Some(5),
predicate: None,
name: None,
expected: None,
};
let err = ChromeTool::build_args(&bad_op).unwrap_err().to_string();
assert!(err.contains("invalid count op"), "err: {err}");
let bad_predicate = ChromeAction::Expect {
condition: "text".into(),
selector: Some("h1".into()),
op: None,
count: None,
predicate: Some("bogus".into()),
name: None,
expected: Some("x".into()),
};
let err = ChromeTool::build_args(&bad_predicate)
.unwrap_err()
.to_string();
assert!(err.contains("invalid predicate"), "err: {err}");
}
#[test]
fn parse_run_output_pins_envelope_over_exit_code() {
let body = json!({"success": true, "data": {"pass": false, "actual": 3, "timedOut": true}});
let resp = match parse_run_output(Some("expect"), false, &body.to_string(), "") {
ParsedRun::Ok(resp) => resp,
other => panic!("expected Ok, got {other:?}"),
};
let outcome = expect_outcome(resp.data.as_ref().expect("data present")).expect("verdict");
assert!(!outcome.pass && outcome.timed_out);
assert_eq!(outcome.actual, Some(json!(3)));
assert!(matches!(
parse_run_output(Some("open"), false, &body.to_string(), ""),
ParsedRun::Failed { .. }
));
let err_body = json!({"success": false, "error": "Browser not launched", "code": "browser_not_launched"});
match parse_run_output(Some("expect"), false, &err_body.to_string(), "") {
ParsedRun::Failed { error, code, .. } => {
assert_eq!(error, "Browser not launched");
assert_eq!(code.as_deref(), Some("browser_not_launched"));
}
other => panic!("expected Failed, got {other:?}"),
}
assert!(matches!(
parse_run_output(None, true, "garbage", ""),
ParsedRun::Unparseable
));
match parse_run_output(None, false, "garbage", "relay is down\n") {
ParsedRun::Failed {
error,
code,
retryable,
} => {
assert_eq!(error, "relay is down");
assert!(code.is_none() && retryable.is_none());
}
other => panic!("expected Failed, got {other:?}"),
}
assert!(matches!(
parse_run_output(
Some("open"),
true,
&json!({"success": true}).to_string(),
""
),
ParsedRun::Ok(_)
));
}
#[test]
fn normalize_extract_schema_shapes() {
let obj = json!({"rows": ".card", "fields": {"title": ".title"}});
let (out, note) = ChromeTool::normalize_extract_schema(&obj).unwrap();
assert_eq!(out, obj);
assert!(note.is_none());
let (out, note) =
ChromeTool::normalize_extract_schema(&json!({"rows": ".card", "title": ".title"}))
.unwrap();
assert_eq!(out, json!({"rows": ".card", "fields": {"title": ".title"}}));
assert!(note.is_some_and(|n| n.contains("fields")));
let stringified = json!("{\"rows\": \".card\", \"fields\": {}}");
let (out, note) = ChromeTool::normalize_extract_schema(&stringified).unwrap();
assert_eq!(out, json!({"rows": ".card", "fields": {}}));
assert!(note.is_none());
let err = ChromeTool::normalize_extract_schema(&json!("not json"))
.unwrap_err()
.to_string();
assert!(err.contains("not valid JSON"), "err: {err}");
assert!(ChromeTool::normalize_extract_schema(&json!(42)).is_err());
let err = ChromeTool::normalize_extract_schema(&json!({"items": [1, 2]}))
.unwrap_err()
.to_string();
assert!(err.contains("must be a"), "err: {err}");
let err = ChromeTool::normalize_extract_schema(&json!({"fields": ".title"}))
.unwrap_err()
.to_string();
assert!(err.contains("\"fields\" must be an object"), "err: {err}");
let err = ChromeTool::normalize_extract_schema(&json!({
"fields": {"link": {"sel": "a", "get": "href"}}
}))
.unwrap_err()
.to_string();
assert!(err.contains('@'), "err: {err}");
assert!(err.contains("getter"), "err: {err}");
assert!(
ChromeTool::normalize_extract_schema(&json!({
"fields": {"u": {"sel": "a", "get": "@href"}}
}))
.is_ok()
);
}
#[test]
fn screenshot_action_normalizes_and_parses() {
let action = serde_json::json!({"screenshot": {}});
let parsed: ChromeAction = serde_json::from_value(action.clone()).unwrap();
assert!(matches!(parsed, ChromeAction::Screenshot {}));
let args = serde_json::json!({"action": "screenshot", "tab": "docs"});
let (normalized, note) =
normalize_action(args.get("action").cloned().unwrap(), &args).unwrap();
assert!(
note.is_some(),
"screenshot should record a normalization note"
);
let parsed: ChromeAction = serde_json::from_value(normalized).unwrap();
assert!(matches!(parsed, ChromeAction::Screenshot {}));
}
#[test]
#[expect(clippy::case_sensitive_file_extension_comparisons)] fn screenshot_output_path_is_safe_and_unique() {
let p = ChromeTool::screenshot_output_path("../../etc/cron.d").unwrap();
let file_name = p.file_name().unwrap().to_string_lossy().into_owned();
assert!(!file_name.contains('/') && !file_name.contains('\\'));
assert!(file_name.ends_with(".png"));
let p2 = ChromeTool::screenshot_output_path("default").unwrap();
assert_ne!(p, p2);
}
#[test]
fn validate_png_accepts_real_png() {
let img = image::RgbaImage::from_pixel(2, 2, image::Rgba([255, 0, 0, 255]));
let dir = std::env::temp_dir().join("mahbot-chrome-test-valid");
std::fs::create_dir_all(&dir).unwrap();
let png_path = dir.join("valid.png");
let mut buf = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
.unwrap();
std::fs::write(&png_path, &buf).unwrap();
let (width, height) = validate_png(&png_path).unwrap();
assert_eq!((width, height), (2, 2));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn validate_png_rejects_corrupt_file() {
let dir = std::env::temp_dir().join("mahbot-chrome-test-corrupt");
std::fs::create_dir_all(&dir).unwrap();
let png_path = dir.join("corrupt.png");
std::fs::write(&png_path, b"not a png").unwrap();
let err = validate_png(&png_path).unwrap_err().to_string();
assert!(err.contains("not a valid PNG"), "err: {err}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn format_action_output_eval_prefers_result() {
let response = ChromeResponse {
data: Some(json!({"origin": "https://x", "result": "hello"})),
..ChromeResponse::default()
};
let out = ChromeTool::format_action_output(
&ChromeAction::Eval { js: "42".into() },
"default",
response,
"",
&[],
);
assert!(out.contains("hello"), "output: {out}");
assert!(
!out.contains('{'),
"should not pretty-print JSON braces: {out}"
);
}
#[test]
fn format_action_output_eval_falls_back_without_result() {
let response = ChromeResponse {
data: Some(json!({"note": "no result key"})),
..ChromeResponse::default()
};
let out = ChromeTool::format_action_output(
&ChromeAction::Eval { js: "42".into() },
"default",
response,
"",
&[],
);
assert!(out.contains('{'), "should fall back to pretty-print: {out}");
}
#[test]
fn format_action_output_appends_warning_for_type_read_back_mismatch() {
let response = ChromeResponse {
data: Some(json!({
"typed": "hi", "readBack": "h",
"warning": "the field does not contain what was typed",
})),
..ChromeResponse::default()
};
let out = ChromeTool::format_action_output(
&ChromeAction::Type {
selector: "#e".into(),
text: "hi".into(),
key_events: false,
},
"default",
response,
"",
&[],
);
assert!(
out.contains("chrome-use warning"),
"output should surface the warning: {out}"
);
assert!(
out.contains("warning: the field does not contain what was typed —"),
"warning text should be rendered unquoted: {out}"
);
assert!(
out.contains("the field does not contain what was typed"),
"output should carry the warning text: {out}"
);
assert!(
out.contains("NOT applied"),
"output should tell the agent to treat the action as not applied: {out}"
);
}
#[test]
fn format_action_output_clean_response_has_no_warning() {
let response = ChromeResponse {
data: Some(json!({"typed": "hi", "readBack": "hi"})),
..ChromeResponse::default()
};
let out = ChromeTool::format_action_output(
&ChromeAction::Type {
selector: "#e".into(),
text: "hi".into(),
key_events: false,
},
"default",
response,
"",
&[],
);
assert!(
!out.contains("chrome-use warning"),
"clean output must not carry a warning: {out}"
);
}
#[tokio::test]
async fn image_payload_attaches_chrome_screenshot() {
let img = image::RgbaImage::from_pixel(2, 2, image::Rgba([255, 0, 0, 255]));
let dir = std::env::temp_dir().join("mahbot-chrome-test");
std::fs::create_dir_all(&dir).unwrap();
let png_path = dir.join("shot.png");
let mut buf = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
.unwrap();
std::fs::write(&png_path, &buf).unwrap();
let tool = ChromeTool::default();
*tool.last_screenshot.lock().unwrap_poison() =
Some(png_path.to_string_lossy().into_owned());
let payload = tool
.image_payload(
&crate::Workspace::default(),
&serde_json::json!({"action": "screenshot", "tab": "default"}),
)
.await
.expect("screenshot must produce an image payload");
assert_eq!(payload.source, crate::tools::ImagePayloadSource::Chrome);
assert_eq!(payload.format, "PNG");
assert!(payload.data_uri.starts_with("data:image/jpeg;base64,"));
assert!(
payload
.attached_annotation()
.starts_with("Chrome screenshot"),
"annotation must describe it as a chrome screenshot: {}",
payload.attached_annotation()
);
let _ = std::fs::remove_dir_all(&dir);
}
#[tokio::test]
async fn image_payload_ignores_non_screenshot_actions() {
let tool = ChromeTool::default();
*tool.last_screenshot.lock().unwrap_poison() = Some("/tmp/stale.png".to_string());
let payload = tool
.image_payload(
&crate::Workspace::default(),
&serde_json::json!({"action": "open", "url": "https://example.com", "tab": "default"}),
)
.await;
assert!(
payload.is_none(),
"non-screenshot action must yield no payload"
);
}
#[test]
fn parse_session_list_extracts_names_from_all_envelope_shapes() {
let latest = serde_json::json!({
"ok": true,
"sessions": [{"name": "default", "pid": 1, "owner": "me"}]
});
assert_eq!(parse_session_list(&latest), vec!["default"]);
let legacy = serde_json::json!({
"success": true,
"data": {"sessions": ["default", "docs"]}
});
assert_eq!(parse_session_list(&legacy), vec!["default", "docs"]);
let mixed = serde_json::json!({
"success": true,
"data": {"sessions": [{"name": "docs", "pid": 2}, "default"]}
});
assert_eq!(parse_session_list(&mixed), vec!["docs", "default"]);
let failed = serde_json::json!({"ok": false, "error": "boom"});
assert!(parse_session_list(&failed).is_empty());
let failed_legacy =
serde_json::json!({"success": false, "data": {"sessions": ["default"]}});
assert_eq!(parse_session_list(&failed_legacy), vec!["default"]);
assert!(
parse_session_list(&serde_json::json!({"ok": true, "sessions": "nope"})).is_empty()
);
assert!(parse_session_list(&serde_json::json!(null)).is_empty());
assert!(parse_session_list(&serde_json::json!({"ok": true, "data": {}})).is_empty());
}
}