use std::collections::{HashMap, HashSet};
use std::fmt::Write;
use std::path::Path;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use crate::chrome::actions;
use crate::chrome::contract::{
ChromeResponse, ERROR_PAGE_PROBE_JS, EXPECT_TIMEOUT_NOTE, ErrorPageProbe, ExpectOutcome,
OutEnvelope, OutKind, chrome_leftover_pipe_note, chrome_use_warning, classify_call_failure,
eval_count, eval_result, expect_outcome, extract_net_error_code, extract_output,
extract_snapshot_text, is_session_unresponsive_error, is_unreachable_tab_error,
net_error_phrase, parse_error_page_probe, parse_first, sanitize_timeout_message,
self_launched_browser_error, self_launched_browser_note, truncated_output_error,
unreachable_tab_message, with_condition_timeout_note,
};
use crate::chrome::forms::{
ExpectCond, ExtractGate, WaitTarget, count_eval_js, describe, expect_args, extract_gate,
parse_count_op, parse_predicate, parse_state, text_value_argv, validate_extract_getters,
wait_args, wait_target,
};
use crate::chrome::spawn::{CliRun, CliSpawn, CliTimeout, spawn_cli};
use crate::chrome::{
CHROME_USE_DECLARED_BUDGET, CHROME_USE_OWN_BUDGET, CLI_EPHEMERAL_PREFIX, CLI_SESSION_PREFIX,
ChromeCallClocks, CliRecovery, DEFAULT_OPEN_TIMEOUT, DEFAULT_STEP_TIMEOUT, KILL_SLACK,
SESSION_STOP_TIMEOUT, clocks, is_blank_page_url, kill_bound, probe_clocks, validate_url,
};
use crate::tools::chrome_daemon::{
CliStatus, SessionRecovery, cli_path, cli_probe, cli_version, ensure_ready_for_actions,
readiness, recover_unresponsive_session,
};
use crate::util::{TOOL_OUTPUT_BUDGET_BYTES, truncate_sandwich};
use serde_json::{Value, json};
const SESSION_LIST_TIMEOUT: Duration = Duration::from_secs(8);
const SESSION_PROBE_TIMEOUT: Duration = Duration::from_secs(20);
const SESSION_RECOVERY_FLOW: &str = "`mahbot chrome session stop <name>`, then re-run the action with \
`--session <name>` to re-create it (cookies persist in the profile; open \
tabs do not)";
fn wedge_recovery_note(recovery: SessionRecovery) -> String {
match recovery {
SessionRecovery::Stopped => recovery.summary().to_string(),
SessionRecovery::Unanswered | SessionRecovery::NotStarted => {
format!(
"{} If it is still wedged, recover with {SESSION_RECOVERY_FLOW}.",
recovery.summary()
)
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum WedgeAction {
Recover,
Probe,
Leave,
}
#[must_use]
fn wedge_action(env: &OutEnvelope, named: bool, killed_by_clock: bool) -> WedgeAction {
if !named {
return WedgeAction::Leave;
}
if env.kind == OutKind::Environment
&& envelope_error(env).is_some_and(is_session_unresponsive_error)
{
return WedgeAction::Recover;
}
if matches!(env.kind, OutKind::Timeout | OutKind::Redesign) && killed_by_clock {
return WedgeAction::Probe;
}
WedgeAction::Leave
}
fn envelope_error(env: &OutEnvelope) -> Option<&str> {
env.payload.get("error").and_then(Value::as_str)
}
fn append_error_note(env: &mut OutEnvelope, note: &str) {
if let Some(obj) = env.payload.as_object_mut()
&& let Some(err) = obj.get("error").and_then(Value::as_str)
{
obj.insert("error".into(), json!(format!("{err} — {note}")));
}
}
async fn recover_session_wedge(
env: &mut OutEnvelope,
session: &str,
named: bool,
killed_by_clock: bool,
) {
let note = match wedge_action(env, named, killed_by_clock) {
WedgeAction::Leave => return,
WedgeAction::Recover => wedge_recovery_note(recover_unresponsive_session(session).await),
WedgeAction::Probe => {
match probe_session_liveness(session).await {
Liveness::Unresponsive => {
wedge_recovery_note(recover_unresponsive_session(session).await)
}
Liveness::Answered => {
"The session answered a liveness probe, so this was a slow call rather than a \
wedged session."
.to_string()
}
Liveness::Inconclusive => {
"The session's liveness could not be established, so its daemon was left \
alone; retry the call, and stop the session by hand if it stays \
unresponsive."
.to_string()
}
}
}
};
append_error_note(env, ¬e);
}
#[derive(Debug, Clone, Copy)]
enum Liveness {
Answered,
Unresponsive,
Inconclusive,
}
async fn probe_session_liveness(session: &str) -> Liveness {
let Some(path) = cli_path() else {
return Liveness::Inconclusive;
};
match spawn_step(
&path,
&["get", "url"],
Some(session),
StepClocks::probe(SESSION_PROBE_TIMEOUT),
None,
)
.await
{
Ok(_) => Liveness::Answered,
Err(f) if f.kind == OutKind::Timeout => Liveness::Unresponsive,
Err(_) => Liveness::Inconclusive,
}
}
#[must_use]
fn top_help() -> String {
let mut out = String::from("mahbot chrome — browser automation CLI\n\n");
out.push_str("Usage:\n");
out.push_str(" mahbot chrome <action> [options]\n\n");
out.push_str("Actions:\n");
for d in actions::ACTIONS.iter().filter(|a| a.cli.is_some()) {
let Some(cli) = d.cli.as_ref() else {
continue;
};
if cli.syntax.is_empty() {
let _ = writeln!(out, " {}", d.name);
} else {
let _ = writeln!(out, " {} {}", d.name, cli.syntax);
}
let _ = writeln!(out, " {}", d.purpose);
}
out.push_str("\nGlobal flags:\n");
out.push_str(
" --session <name> use/name a session — letters, digits, '-' or '_' only (not valid for status / session subcommands)\n\n",
);
out.push_str("Output (stdout, one JSON line):\n");
out.push_str(
" {\"schema\":1,\"action\":\"...\",\"ok\":true|false,\"kind\":\"...\",...payload}\n\n",
);
out.push_str("Exit codes:\n");
out.push_str(" 0 ok / empty (including a legitimately empty region)\n");
out.push_str(" 1 site/data step failure (timeout, network, redesign, not-found, error)\n");
out.push_str(" 2 environment failure (no chrome-use CLI/relay/Chrome/display)\n");
out.push_str(" 3 usage error\n\n");
out.push_str("Help:\n");
out.push_str(" mahbot chrome -h this help\n");
out.push_str(" mahbot chrome <action> -h per-action help (flags, kinds, examples)\n");
out
}
#[must_use]
fn action_help(name: &str) -> String {
let Some(d) = actions::desc(name) else {
return String::new();
};
let Some(cli) = d.cli.as_ref() else {
return String::new();
};
let mut out = String::new();
let _ = write!(out, "mahbot chrome {} — {}\n\n", d.name, d.purpose);
out.push_str("Usage:\n");
if cli.syntax.is_empty() {
let _ = writeln!(out, " mahbot chrome {}", d.name);
} else {
let _ = writeln!(out, " mahbot chrome {} {}", d.name, cli.syntax);
}
let mut flag_rows: Vec<(&str, &str)> = cli.flags.to_vec();
if cli.session {
flag_rows.push((
"--session <name>",
"use/name a session — letters, digits, '-' or '_' only (not valid for status / session subcommands)",
));
}
if !flag_rows.is_empty() {
let width = flag_rows.iter().map(|(f, _)| f.len()).max().unwrap_or(0);
out.push_str("\nFlags:\n");
for (flag, desc) in flag_rows {
let _ = writeln!(out, " {flag:<width$} {desc}");
}
}
out.push_str("\nKinds (stdout \"kind\" → exit code):\n");
let kinds = cli
.kinds
.iter()
.map(|k| format!("{} ({})", k.as_str(), k.exit_code()))
.collect::<Vec<_>>()
.join(" · ");
let _ = writeln!(out, " {kinds}");
if !cli.details.is_empty() {
let _ = write!(out, "\n{}\n", cli.details);
}
out.push_str("\nExamples:\n");
for ex in cli.examples {
let _ = writeln!(out, " {ex}");
}
out
}
fn action_help_request(args: &[String]) -> Option<&'static str> {
let (i, word) = first_positional(args)?;
let d = actions::desc(word)?;
d.cli
.as_ref()
.is_some_and(|_| args[i + 1..].iter().any(|t| t == "-h" || t == "--help"))
.then_some(d.name)
}
struct CliSession {
name: String,
ephemeral: bool,
}
struct Invocation {
action: Action,
session: Option<String>,
}
enum Action {
Status,
Open {
url: String,
expect: Option<String>,
structural: bool,
timeout: Duration,
},
Count {
selector: String,
bound: Option<Duration>,
},
Wait {
target: WaitTarget,
timeout: Duration,
},
Expect {
cond: ExpectCond,
timeout: Duration,
},
Eval {
js: String,
bound: Option<Duration>,
},
Extract {
schema_file: String,
limit: Option<usize>,
bound: Option<Duration>,
},
Click {
selector: String,
if_present: bool,
bound: Option<Duration>,
},
Fill {
selector: String,
source: TextInput,
bound: Option<Duration>,
},
Type {
selector: String,
text: String,
key_events: bool,
bound: Option<Duration>,
},
Press {
key: String,
selector: Option<String>,
hold: Option<u64>,
bound: Option<Duration>,
},
SessionStop {
name: String,
force: bool,
},
SessionStatus {
name: String,
},
}
impl Action {
fn name(&self) -> &'static str {
match self {
Action::Status => "status",
Action::Open { .. } => "open",
Action::Count { .. } => "count",
Action::Wait { .. } => "wait",
Action::Expect { .. } => "expect",
Action::Eval { .. } => "eval",
Action::Extract { .. } => "extract",
Action::Click { .. } => "click",
Action::Fill { .. } => "fill",
Action::Type { .. } => "type",
Action::Press { .. } => "press",
Action::SessionStop { .. } | Action::SessionStatus { .. } => "session",
}
}
}
enum TextInput {
Inline(String),
File(String),
Stdin,
}
impl TextInput {
fn argv(&self) -> Vec<String> {
match self {
Self::Inline(t) => text_value_argv(t),
Self::File(p) => vec!["--file".into(), p.clone()],
Self::Stdin => vec!["--stdin".into()],
}
}
}
#[derive(Debug)]
struct StepFailure {
kind: OutKind,
message: String,
}
impl StepFailure {
fn envelope(self, action: &str, base: Value, clocks: StepClocks) -> OutEnvelope {
let mut obj = match base {
Value::Object(m) => m,
other => {
let mut m = serde_json::Map::new();
m.insert("error".into(), other);
m
}
};
if self.kind == OutKind::Timeout && self.message.is_empty() {
obj.insert("timeout_ms".into(), json!(clocks.call.kill.as_millis()));
obj.insert(
"error".into(),
json!(deadline_error(
clocks.call.chrome_side,
clocks.call.kill,
"the step did not complete"
)),
);
} else {
let message = if is_unreachable_tab_error(&self.message) {
unreachable_tab_message(&self.message)
} else {
self.message.clone()
};
obj.insert("error".into(), json!(message));
}
if self.kind == OutKind::Network
&& let Some(code) = extract_net_error_code(&self.message)
{
obj.insert("error_code".into(), json!(code));
}
out_env(action, false, self.kind, Value::Object(obj))
}
}
type StepOutcome = Result<ChromeResponse, StepFailure>;
static CHROME_USE_SPAWNED: AtomicBool = AtomicBool::new(false);
static CHROME_USE_LEFTOVER_PIPES: AtomicBool = AtomicBool::new(false);
static CLI_STEP_KILLED_BY_CLOCK: AtomicBool = AtomicBool::new(false);
pub async fn run_cli(args: &[String]) -> i32 {
CHROME_USE_SPAWNED.store(false, Ordering::Relaxed);
CHROME_USE_LEFTOVER_PIPES.store(false, Ordering::Relaxed);
CLI_STEP_KILLED_BY_CLOCK.store(false, Ordering::Relaxed);
if let Some(a) = args.first()
&& (a == "-h" || a == "--help")
{
print!("{}", top_help());
return 0;
}
if let Some(action) = action_help_request(args) {
print!("{}", action_help(action));
return 0;
}
let invocation = match parse_invocation(args) {
Ok(inv) => inv,
Err(msg) => {
let action = recognized_action(args);
eprintln!("mahbot chrome: {msg}");
eprintln!("run 'mahbot chrome --help' for usage.");
out_env(&action, false, OutKind::Usage, json!({ "error": msg })).emit();
return 3;
}
};
let (envelope, session) = dispatch(&invocation).await;
envelope.emit();
if let Some(s) = session.as_ref().filter(|s| s.ephemeral)
&& CHROME_USE_SPAWNED.load(Ordering::Relaxed)
{
CHROME_USE_LEFTOVER_PIPES.store(false, Ordering::Relaxed);
close_ephemeral(&s.name).await;
if CHROME_USE_LEFTOVER_PIPES.load(Ordering::Relaxed) {
eprintln!("mahbot chrome: {}", chrome_leftover_pipe_note(&s.name));
}
}
envelope.kind.exit_code()
}
type FlagSet = (&'static [&'static str], &'static [&'static str]);
const ACTION_FLAGS: &[(&str, &[&str], &[&str])] = &[
("status", &[], &[]),
("open", &["expect", "timeout"], &["structural"]),
("count", &["timeout"], &[]),
("wait", &["timeout", "url", "text"], &[]),
("expect", &["timeout"], &[]),
("eval", &["timeout"], &[]),
("extract", &["schema-file", "limit", "timeout"], &[]),
("click", &["timeout"], &["if-present"]),
("fill", &["file", "timeout"], &["stdin"]),
("type", &["timeout"], &["key-events"]),
("press", &["selector", "hold", "timeout"], &[]),
("session", &[], &["force"]),
];
fn action_flags(word: &str) -> FlagSet {
ACTION_FLAGS
.iter()
.find(|(name, ..)| *name == word)
.map_or((&[], &[]), |(_, v, b)| (*v, *b))
}
fn parse_invocation(args: &[String]) -> Result<Invocation, String> {
let (session, remaining) = extract_global_session(args)?;
let action_word = remaining
.first()
.ok_or_else(|| "no action given".to_string())?;
let rest = &remaining[1..];
let allowed = action_flags(action_word);
match action_word.as_str() {
"status" => parse_status(session.as_deref(), rest),
"open" => parse_open(session.as_deref(), rest, allowed),
"count" => parse_count(session.as_deref(), rest, allowed),
"wait" => parse_wait(session.as_deref(), rest, allowed),
"expect" => parse_expect(session.as_deref(), rest, allowed),
"eval" => parse_eval(session.as_deref(), rest, allowed),
"extract" => parse_extract(session.as_deref(), rest, allowed),
"click" => parse_click(session.as_deref(), rest, allowed),
"fill" => parse_fill(session.as_deref(), rest, allowed),
"type" => parse_type(session.as_deref(), rest, allowed),
"press" => parse_press(session.as_deref(), rest, allowed),
"session" => parse_session(session.as_deref(), rest, allowed),
other => Err(format!("unknown action '{other}'")),
}
}
fn extract_global_session(args: &[String]) -> Result<(Option<String>, Vec<String>), String> {
let mut session: Option<String> = None;
let mut remaining = Vec::new();
let mut i = 0;
while i < args.len() {
let a = &args[i];
if a == "--" {
remaining.extend_from_slice(&args[i..]);
break;
}
if a == "--session" {
i += 1;
let val = args
.get(i)
.ok_or_else(|| "missing value for --session".to_string())?;
validate_session_name(val)?;
session = Some(val.clone());
i += 1;
} else if let Some(val) = a.strip_prefix("--session=") {
if val.is_empty() {
return Err("missing value for --session".to_string());
}
validate_session_name(val)?;
session = Some(val.to_string());
i += 1;
} else {
remaining.push(a.clone());
i += 1;
}
}
Ok((session, remaining))
}
fn first_positional(args: &[String]) -> Option<(usize, &str)> {
let mut i = 0;
while i < args.len() {
let a = &args[i];
if a == "--session" {
i += 2;
} else if a.starts_with('-') {
i += 1;
} else {
return Some((i, a));
}
}
None
}
fn recognized_action(args: &[String]) -> String {
match first_positional(args) {
Some((_, a)) if actions::is_cli_action(a) => a.to_string(),
_ => "usage".to_string(),
}
}
fn parse_flags(
args: &[String],
value_flags: &[&str],
bool_flags: &[&str],
) -> Result<(Vec<String>, Flags), String> {
parse_flags_impl(args, value_flags, bool_flags, false)
}
fn parse_flags_text(
args: &[String],
value_flags: &[&str],
bool_flags: &[&str],
) -> Result<(Vec<String>, Flags), String> {
parse_flags_impl(args, value_flags, bool_flags, true)
}
fn parse_flags_impl(
args: &[String],
value_flags: &[&str],
bool_flags: &[&str],
single_dash_is_text: bool,
) -> Result<(Vec<String>, Flags), String> {
let mut positionals = Vec::new();
let mut flags = Flags::default();
let mut i = 0;
while i < args.len() {
let a = &args[i];
if a == "--" {
positionals.extend(args[i + 1..].iter().cloned());
break;
}
if let Some(body) = a.strip_prefix("--") {
let (name, eq_value) = match body.split_once('=') {
Some((n, v)) => (n, Some(v)),
None => (body, None),
};
if value_flags.contains(&name) {
let value = match eq_value {
Some(v) if !v.is_empty() => v.to_string(),
Some(_) => return Err(format!("missing value for --{name}")),
None => {
i += 1;
args.get(i)
.ok_or_else(|| format!("missing value for --{name}"))?
.clone()
}
};
flags.values.insert(name.to_string(), value);
i += 1;
} else if bool_flags.contains(&name) {
if eq_value.is_some() {
return Err(format!("flag --{name} does not take a value"));
}
flags.bools.insert(name.to_string());
i += 1;
} else {
return Err(format!("unknown flag --{name}"));
}
continue;
}
if a.starts_with('-') && a.len() > 1 && !single_dash_is_text {
return Err(format!("unknown flag {a}"));
}
positionals.push(a.clone());
i += 1;
}
Ok((positionals, flags))
}
#[derive(Default)]
struct Flags {
values: HashMap<String, String>,
bools: HashSet<String>,
}
impl Flags {
#[must_use]
fn value(&self, name: &str) -> Option<&str> {
self.values.get(name).map(String::as_str)
}
#[must_use]
fn has(&self, name: &str) -> bool {
self.bools.contains(name)
}
}
fn parse_status(session: Option<&str>, rest: &[String]) -> Result<Invocation, String> {
if session.is_some() {
return Err("--session is not valid for status".to_string());
}
if !rest.is_empty() {
return Err("unexpected arguments for status".to_string());
}
Ok(Invocation {
action: Action::Status,
session: None,
})
}
fn parse_open(
session: Option<&str>,
rest: &[String],
allowed: FlagSet,
) -> Result<Invocation, String> {
let (value_flags, bool_flags) = allowed;
let (positionals, flags) = parse_flags(rest, value_flags, bool_flags)?;
let url = take_positional(&positionals, 0, "url")?;
reject_extra_positionals(&positionals, 1)?;
Ok(Invocation {
action: Action::Open {
url,
expect: flags.value("expect").map(String::from),
structural: flags.has("structural"),
timeout: parse_forwarded_timeout(&flags)?.unwrap_or(DEFAULT_OPEN_TIMEOUT),
},
session: session.map(String::from),
})
}
fn parse_count(
session: Option<&str>,
rest: &[String],
allowed: FlagSet,
) -> Result<Invocation, String> {
let (value_flags, bool_flags) = allowed;
let (positionals, flags) = parse_flags(rest, value_flags, bool_flags)?;
let selector = take_positional(&positionals, 0, "selector")?;
reject_extra_positionals(&positionals, 1)?;
Ok(Invocation {
action: Action::Count {
selector,
bound: parse_bound_timeout(&flags)?,
},
session: session.map(String::from),
})
}
fn parse_wait(
session: Option<&str>,
rest: &[String],
allowed: FlagSet,
) -> Result<Invocation, String> {
let (value_flags, bool_flags) = allowed;
let (positionals, flags) = parse_flags(rest, value_flags, bool_flags)?;
let target = wait_target(
positionals.first().map(String::as_str),
flags.value("url"),
flags.value("text"),
)?;
reject_extra_positionals(&positionals, 1)?;
Ok(Invocation {
action: Action::Wait {
target,
timeout: parse_forwarded_timeout(&flags)?.unwrap_or(DEFAULT_STEP_TIMEOUT),
},
session: session.map(String::from),
})
}
fn parse_expect(
session: Option<&str>,
rest: &[String],
allowed: FlagSet,
) -> Result<Invocation, String> {
let (value_flags, bool_flags) = allowed;
let (positionals, flags) = parse_flags(rest, value_flags, bool_flags)?;
let cond = parse_expect_cond(&positionals)?;
Ok(Invocation {
action: Action::Expect {
cond,
timeout: parse_forwarded_timeout(&flags)?.unwrap_or(DEFAULT_STEP_TIMEOUT),
},
session: session.map(String::from),
})
}
#[expect(clippy::too_many_lines)]
fn parse_expect_cond(ps: &[String]) -> Result<ExpectCond, String> {
let usage = "expect <selector> <visible|hidden|present> | count <sel> <op> <n> | text|value <sel> <equals|contains|matches> <value> | attr <sel> <name> <pred> <value> | url <pred> <pattern>";
let first = ps
.first()
.map(String::as_str)
.ok_or_else(|| format!("missing condition — {usage}"))?;
match first {
"count" => {
if ps.len() != 4 {
return Err("expect count takes exactly: count <selector> <op> <n>".to_string());
}
let op = parse_count_op(&ps[2]).ok_or_else(|| {
format!(
"invalid count op '{}' — use == != > < >= <= (or eq ne gt lt ge le)",
ps[2]
)
})?;
let n: u64 = ps[3]
.parse()
.map_err(|_| format!("count comparison needs a number, got '{}'", ps[3]))?;
Ok(ExpectCond::Count {
selector: ps[1].clone(),
op,
n,
})
}
"text" | "value" => {
if ps.len() < 4 {
return Err(format!(
"expect {first} takes: {first} <selector> <equals|contains|matches> <value>"
));
}
let predicate = parse_predicate(&ps[2]).ok_or_else(|| {
format!(
"invalid predicate '{}' — use equals|contains|matches",
ps[2]
)
})?;
let value = ps[3..].join(" ");
let selector = ps[1].clone();
Ok(if first == "text" {
ExpectCond::Text {
selector,
predicate,
value,
}
} else {
ExpectCond::Value {
selector,
predicate,
value,
}
})
}
"attr" => {
if ps.len() < 5 {
return Err(
"expect attr takes: attr <selector> <name> <equals|contains|matches> <value>"
.to_string(),
);
}
let predicate = parse_predicate(&ps[3]).ok_or_else(|| {
format!(
"invalid predicate '{}' — use equals|contains|matches",
ps[3]
)
})?;
Ok(ExpectCond::Attr {
selector: ps[1].clone(),
name: ps[2].clone(),
predicate,
value: ps[4..].join(" "),
})
}
"url" => {
if ps.len() < 3 {
return Err("expect url takes: url <equals|contains|matches> <pattern>".to_string());
}
let predicate = parse_predicate(&ps[1]).ok_or_else(|| {
format!(
"invalid predicate '{}' — use equals|contains|matches",
ps[1]
)
})?;
Ok(ExpectCond::Url {
predicate,
pattern: ps[2..].join(" "),
})
}
sel => {
if ps.len() != 2 {
return Err(format!("expect takes a selector and one state — {usage}"));
}
let state = parse_state(&ps[1]).ok_or_else(|| {
format!(
"unsupported condition '{}' — allowed states: visible|hidden|present (count/text/value/attr/url have their own forms)",
ps[1]
)
})?;
Ok(ExpectCond::State {
selector: sel.to_string(),
state,
})
}
}
}
fn parse_eval(
session: Option<&str>,
rest: &[String],
allowed: FlagSet,
) -> Result<Invocation, String> {
let (value_flags, bool_flags) = allowed;
let (positionals, flags) = parse_flags(rest, value_flags, bool_flags)?;
let js = take_positional(&positionals, 0, "js")?;
reject_extra_positionals(&positionals, 1)?;
Ok(Invocation {
action: Action::Eval {
js,
bound: parse_bound_timeout(&flags)?,
},
session: session.map(String::from),
})
}
fn parse_extract(
session: Option<&str>,
rest: &[String],
allowed: FlagSet,
) -> Result<Invocation, String> {
let (value_flags, bool_flags) = allowed;
let (positionals, flags) = parse_flags(rest, value_flags, bool_flags)?;
if !positionals.is_empty() {
return Err(format!("unexpected argument '{}'", positionals[0]));
}
let schema_file = flags
.value("schema-file")
.ok_or_else(|| "missing required --schema-file <path>".to_string())?
.to_string();
Ok(Invocation {
action: Action::Extract {
schema_file,
limit: parse_limit_flag(&flags)?,
bound: parse_bound_timeout(&flags)?,
},
session: session.map(String::from),
})
}
fn parse_click(
session: Option<&str>,
rest: &[String],
allowed: FlagSet,
) -> Result<Invocation, String> {
let (value_flags, bool_flags) = allowed;
let (positionals, flags) = parse_flags(rest, value_flags, bool_flags)?;
let selector = take_positional(&positionals, 0, "selector")?;
reject_extra_positionals(&positionals, 1)?;
Ok(Invocation {
action: Action::Click {
selector,
if_present: flags.has("if-present"),
bound: parse_bound_timeout(&flags)?,
},
session: session.map(String::from),
})
}
fn parse_fill(
session: Option<&str>,
rest: &[String],
allowed: FlagSet,
) -> Result<Invocation, String> {
let (value_flags, bool_flags) = allowed;
let (positionals, flags) = parse_flags_text(rest, value_flags, bool_flags)?;
let usage = "fill <selector> <text | --file <path> | --stdin>";
let selector = match positionals.first() {
Some(s) => s.clone(),
None => return Err(format!("missing selector — {usage}")),
};
let inline = positionals.get(1..).unwrap_or_default();
let file = flags.value("file");
let stdin = flags.has("stdin");
let source = match (file, stdin, inline.is_empty()) {
(Some(f), false, true) => TextInput::File(f.to_string()),
(None, true, true) => TextInput::Stdin,
(None, false, false) => TextInput::Inline(inline.join(" ")),
_ => return Err(format!("exactly one text source required — {usage}")),
};
Ok(Invocation {
action: Action::Fill {
selector,
source,
bound: parse_bound_timeout(&flags)?,
},
session: session.map(String::from),
})
}
fn parse_type(
session: Option<&str>,
rest: &[String],
allowed: FlagSet,
) -> Result<Invocation, String> {
let (value_flags, bool_flags) = allowed;
let (positionals, flags) = parse_flags_text(rest, value_flags, bool_flags)?;
let usage = "type <selector> <text> [--key-events]";
let selector = match positionals.first() {
Some(s) => s.clone(),
None => return Err(format!("missing selector — {usage}")),
};
let text = positionals.get(1..).unwrap_or_default().join(" ");
if text.is_empty() {
return Err(format!("missing text — {usage}"));
}
Ok(Invocation {
action: Action::Type {
selector,
text,
key_events: flags.has("key-events"),
bound: parse_bound_timeout(&flags)?,
},
session: session.map(String::from),
})
}
fn parse_press(
session: Option<&str>,
rest: &[String],
allowed: FlagSet,
) -> Result<Invocation, String> {
let (value_flags, bool_flags) = allowed;
let (positionals, flags) = parse_flags(rest, value_flags, bool_flags)?;
let usage = "press <key> [--selector <sel>] [--hold <ms>]";
let key = match positionals.first() {
Some(s) => s.clone(),
None => return Err(format!("missing key — {usage}")),
};
reject_extra_positionals(&positionals, 1)?;
let hold = match flags.value("hold") {
Some(v) => Some(
v.parse::<u64>()
.map_err(|_| format!("--hold must be an integer (ms): {v}"))?,
),
None => None,
};
Ok(Invocation {
action: Action::Press {
key,
selector: flags.value("selector").map(String::from),
hold,
bound: parse_bound_timeout(&flags)?,
},
session: session.map(String::from),
})
}
fn parse_session(
session: Option<&str>,
rest: &[String],
allowed: FlagSet,
) -> Result<Invocation, String> {
if session.is_some() {
return Err("--session is not valid for session stop/status".to_string());
}
let sub = rest
.first()
.map(String::as_str)
.ok_or_else(|| "usage: session stop <name> | session status <name>".to_string())?;
match sub {
"stop" => {
let (value_flags, bool_flags) = allowed;
let (positionals, flags) = parse_flags(&rest[1..], value_flags, bool_flags)?;
let name = take_positional(&positionals, 0, "name")?;
validate_session_name(&name)?;
reject_extra_positionals(&positionals, 1)?;
Ok(Invocation {
action: Action::SessionStop {
name,
force: flags.has("force"),
},
session: None,
})
}
"status" => {
let (value_flags, bool_flags) = allowed;
let (positionals, flags) = parse_flags(&rest[1..], value_flags, bool_flags)?;
if flags.has("force") {
return Err("--force is not valid for session status".to_string());
}
let name = take_positional(&positionals, 0, "name")?;
validate_session_name(&name)?;
reject_extra_positionals(&positionals, 1)?;
Ok(Invocation {
action: Action::SessionStatus { name },
session: None,
})
}
other => Err(format!(
"unknown session subcommand '{other}' (expected 'stop' or 'status')"
)),
}
}
fn take_positional(positionals: &[String], idx: usize, name: &str) -> Result<String, String> {
positionals
.get(idx)
.cloned()
.ok_or_else(|| format!("missing positional: {name}"))
}
fn reject_extra_positionals(positionals: &[String], expected: usize) -> Result<(), String> {
if positionals.len() > expected {
return Err(format!("unexpected argument '{}'", positionals[expected]));
}
Ok(())
}
fn parse_forwarded_timeout(flags: &Flags) -> Result<Option<Duration>, String> {
let Some(secs) = parse_timeout_secs(flags)? else {
return Ok(None);
};
if secs >= CHROME_USE_OWN_BUDGET.as_secs() {
return Err(format!(
"--timeout {secs}s is at or above chrome-use's own client tolerance ({}s): \
chrome-use cannot work past it, so a deadline that long makes it run out of \
tolerance instead of reporting its own reason, and its session-unresponsive \
verdict replaces the honest one. Use less than {}s.",
CHROME_USE_OWN_BUDGET.as_secs(),
CHROME_USE_OWN_BUDGET.as_secs()
));
}
Ok(Some(Duration::from_secs(secs)))
}
fn parse_bound_timeout(flags: &Flags) -> Result<Option<Duration>, String> {
let Some(secs) = parse_timeout_secs(flags)? else {
return Ok(None);
};
let declared = CHROME_USE_DECLARED_BUDGET.as_secs();
if secs < declared {
return Err(format!(
"--timeout {secs}s cannot be honoured: chrome-use takes no per-call deadline for this \
verb, so the call runs to the {declared}s clock mahbot declares to chrome-use and \
mahbot never cuts the call off below it — the reported bound would not be the one you \
asked for. Give at least {declared}s (that is the shortest bound the call can be given), \
or omit --timeout. `wait`/`expect` — and `open`'s --expect wait — are the verbs that \
forward a deadline chrome-use honours in full."
));
}
Ok(Some(Duration::from_secs(secs)))
}
fn parse_timeout_secs(flags: &Flags) -> Result<Option<u64>, String> {
match flags.value("timeout") {
Some(v) => {
let secs: u64 = v
.parse()
.map_err(|_| format!("--timeout must be an integer: {v}"))?;
if secs < 1 {
return Err("--timeout must be at least 1 second".to_string());
}
Ok(Some(secs))
}
None => Ok(None),
}
}
fn parse_limit_flag(flags: &Flags) -> Result<Option<usize>, String> {
match flags.value("limit") {
Some(v) => {
let n: usize = v
.parse()
.map_err(|_| format!("--limit must be an integer: {v}"))?;
Ok(Some(n))
}
None => Ok(None),
}
}
fn resolve_session(flag: Option<&str>) -> (String, bool) {
match flag {
Some(name) => {
let name = if name.starts_with(CLI_SESSION_PREFIX) {
name.to_string()
} else {
format!("{CLI_SESSION_PREFIX}{name}")
};
(name, false)
}
None => (
format!("{CLI_EPHEMERAL_PREFIX}{}", crate::generate_suffix()),
true,
),
}
}
const PROTECTED_SESSION_PREFIXES: [&str; 2] = ["agent-tab-", "link-enricher-"];
fn validate_session_name(name: &str) -> Result<(), String> {
let plain = !name.is_empty()
&& name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_'));
if plain {
Ok(())
} else {
Err(format!(
"invalid session name {name:?} — use letters, digits, '-' or '_'"
))
}
}
fn resolve_session_target(name: &str) -> String {
if name.starts_with(CLI_SESSION_PREFIX)
|| PROTECTED_SESSION_PREFIXES
.iter()
.any(|p| name.starts_with(p))
{
return name.to_string();
}
format!("{CLI_SESSION_PREFIX}{name}")
}
fn resolve_stop_target(name: &str, force: bool) -> Result<String, String> {
let target = resolve_session_target(name);
if !force
&& PROTECTED_SESSION_PREFIXES
.iter()
.any(|p| target.starts_with(p))
{
return Err(format!(
"{name} is not a mahbot-chrome session; pass --force to stop it anyway"
));
}
Ok(target)
}
fn validate_action_args(action: &Action) -> Option<OutEnvelope> {
let Action::Open { url, expect, .. } = action else {
return None;
};
open_args(url, expect.as_deref()).err()
}
async fn dispatch(invocation: &Invocation) -> (OutEnvelope, Option<CliSession>) {
match &invocation.action {
Action::Status => (stamped(status()).await, None),
Action::SessionStop { name, force } => (stamped(session_stop(name, *force)).await, None),
Action::SessionStatus { name } => (stamped(session_status(name)).await, None),
action => {
let started = Instant::now();
let (name, ephemeral) = resolve_session(invocation.session.as_deref());
if let Some(mut refusal) = validate_action_args(action) {
surface_session(&mut refusal, &name);
stamp_elapsed(&mut refusal, started);
return (refusal, None);
}
if cli_path().is_none() {
let error = "chrome-use CLI not found";
eprintln!("mahbot chrome: {error}");
let mut env = env_failure(action.name(), json!({}), error);
surface_session(&mut env, &name);
stamp_elapsed(&mut env, started);
return (env, None);
}
if let Err(refusal) = ensure_ready_for_actions().await {
eprintln!("mahbot chrome: {refusal}");
let mut env = out_env(
action.name(),
false,
OutKind::Environment,
json!({ "error": refusal }),
);
surface_session(&mut env, &name);
stamp_elapsed(&mut env, started);
return (env, None);
}
CLI_STEP_KILLED_BY_CLOCK.store(false, Ordering::Relaxed);
let mut env = match action {
Action::Open {
url,
expect,
structural,
timeout,
} => open(url, expect.as_deref(), *structural, *timeout, &name).await,
Action::Count { selector, bound } => count(selector, *bound, &name).await,
Action::Wait { target, timeout } => wait(target, *timeout, &name).await,
Action::Expect { cond, timeout } => expect(cond, *timeout, &name).await,
Action::Eval { js, bound } => eval(js, *bound, &name).await,
Action::Extract {
schema_file,
limit,
bound,
} => extract(schema_file, *limit, *bound, &name).await,
Action::Click {
selector,
if_present,
bound,
} => click(selector, *if_present, *bound, &name).await,
Action::Fill {
selector,
source,
bound,
} => fill(selector, source, *bound, &name).await,
Action::Type {
selector,
text,
key_events,
bound,
} => r#type(selector, text, *key_events, *bound, &name).await,
Action::Press {
key,
selector,
hold,
bound,
} => press(key, selector.as_deref(), *hold, *bound, &name).await,
Action::Status | Action::SessionStop { .. } | Action::SessionStatus { .. } => {
unreachable!("handled by the outer match")
}
};
let killed_by_clock = CLI_STEP_KILLED_BY_CLOCK.load(Ordering::Relaxed);
surface_session(&mut env, &name);
stamp_elapsed(&mut env, started);
if CHROME_USE_LEFTOVER_PIPES.load(Ordering::Relaxed) {
surface_leftover_note(&mut env, &name);
}
recover_session_wedge(&mut env, &name, !ephemeral, killed_by_clock).await;
(env, Some(CliSession { name, ephemeral }))
}
}
}
fn surface_leftover_note(env: &mut OutEnvelope, name: &str) {
if let Some(obj) = env.payload.as_object_mut() {
obj.insert("leftover".into(), json!(chrome_leftover_pipe_note(name)));
}
}
fn surface_session(env: &mut OutEnvelope, name: &str) {
if let Some(obj) = env.payload.as_object_mut() {
obj.insert("session".into(), json!(name));
}
}
fn stamp_elapsed(env: &mut OutEnvelope, started: Instant) {
if let Some(obj) = env.payload.as_object_mut() {
obj.insert("elapsed_ms".into(), json!(started.elapsed().as_millis()));
}
}
async fn stamped<F: std::future::Future<Output = OutEnvelope>>(f: F) -> OutEnvelope {
let started = Instant::now();
let mut env = f.await;
stamp_elapsed(&mut env, started);
env
}
#[derive(Debug, Clone, Copy)]
struct StepClocks {
call: ChromeCallClocks,
recovery: CliRecovery,
}
impl StepClocks {
fn of(declared: Option<Duration>, recovery: CliRecovery) -> Self {
Self {
call: clocks(declared, recovery),
recovery,
}
}
fn forwarded(deadline: Duration) -> Self {
Self::of(Some(deadline), CliRecovery::Allowed)
}
fn tool_clock(own_bound: Option<Duration>) -> Self {
let recovery = CliRecovery::Allowed;
let bound = own_bound.unwrap_or(CHROME_USE_DECLARED_BUDGET);
Self {
call: ChromeCallClocks {
chrome_side: CHROME_USE_DECLARED_BUDGET,
kill: kill_bound(bound, recovery),
},
recovery,
}
}
fn operation() -> Self {
Self::of(None, CliRecovery::Allowed)
}
fn bounded(chrome_side: Duration, bound: Duration) -> Self {
let recovery = CliRecovery::Suppressed;
Self {
call: ChromeCallClocks {
chrome_side,
kill: kill_bound(bound, recovery),
},
recovery,
}
}
fn probe(bound: Duration) -> Self {
let recovery = CliRecovery::Suppressed;
Self {
call: probe_clocks(bound),
recovery,
}
}
}
async fn spawn_step(
path: &Path,
args: &[&str],
session: Option<&str>,
clocks: StepClocks,
input: Option<&[u8]>,
) -> StepOutcome {
if session.is_some() {
CHROME_USE_SPAWNED.store(true, Ordering::Relaxed);
}
let failed = |kind: OutKind, message: String| Err(StepFailure { kind, message });
match spawn_cli(CliSpawn {
path,
args,
session,
json: true,
capture_stderr: true,
timeout: CliTimeout::Bounded(clocks.call.kill),
cancel_kills: true,
input: input.map(<[u8]>::to_vec),
chrome_side: clocks.call.chrome_side,
recovery: clocks.recovery,
})
.await
{
CliRun::SpawnFailure => failed(
OutKind::Environment,
"chrome-use CLI could not be spawned/found".to_string(),
),
CliRun::TimedOut => {
if matches!(clocks.recovery, CliRecovery::Allowed)
&& clocks.call.kill > clocks.call.chrome_side
{
CLI_STEP_KILLED_BY_CLOCK.store(true, Ordering::Relaxed);
}
Err(StepFailure {
kind: OutKind::Timeout,
message: String::new(),
})
}
CliRun::Output(out) => {
CHROME_USE_LEFTOVER_PIPES.fetch_or(out.leftover_pipes, Ordering::Relaxed);
classify_step_output(
args.first() == Some(&"expect"),
out.truncated,
out.status.code(),
out.status.success(),
&out.stdout,
String::from_utf8_lossy(&out.stderr).trim(),
)
}
}
}
fn classify_step_output(
expect_style: bool,
truncated: bool,
exit_code: Option<i32>,
status_success: bool,
stdout: &[u8],
stderr: &str,
) -> StepOutcome {
let failed = |kind: OutKind, message: String| Err(StepFailure { kind, message });
let fallback = || fallback_step_message(exit_code, stderr);
let classified = |resp: ChromeResponse| {
let code = resp.code.clone();
let msg = resp
.error
.filter(|e| !e.trim().is_empty())
.unwrap_or_else(fallback);
let kind = classify_call_failure(code.as_deref(), &msg);
failed(kind, sanitize_timeout_message(kind, &msg))
};
let parsed: Option<ChromeResponse> = parse_first::<ChromeResponse>(stdout);
if !status_success {
return match parsed {
Some(resp) if expect_style && resp.is_success() => {
self_launched_failure(&resp).map_or(Ok(resp), Err)
}
Some(resp) => classified(resp),
None if truncated => failed(OutKind::Error, truncated_output_error()),
None => {
let msg = fallback();
let kind = classify_call_failure(None, &msg);
failed(kind, sanitize_timeout_message(kind, &msg))
}
};
}
match parsed {
Some(resp) if resp.is_success() => self_launched_failure(&resp).map_or(Ok(resp), Err),
Some(resp) => classified(resp),
None => failed(
OutKind::Error,
if truncated {
truncated_output_error()
} else if stderr.is_empty() {
"chrome-use returned non-JSON output".to_string()
} else {
stderr.to_string()
},
),
}
}
fn self_launched_failure(resp: &ChromeResponse) -> Option<StepFailure> {
self_launched_browser_note(resp).map(|note| StepFailure {
kind: OutKind::Environment,
message: self_launched_browser_error(¬e),
})
}
#[must_use]
fn fallback_step_message(status_code: Option<i32>, stderr: &str) -> String {
if stderr.is_empty() {
match status_code {
Some(c) => format!("chrome-use exited with code {c}"),
None => "chrome-use exited with a non-zero status".to_string(),
}
} else {
stderr.to_string()
}
}
fn kind_contract_holds(action: &str, kind: OutKind) -> bool {
match actions::desc(action) {
None => true,
Some(d) => d.cli.as_ref().is_none_or(|c| c.kinds.contains(&kind)),
}
}
#[must_use]
fn out_env(action: &str, ok: bool, kind: OutKind, payload: Value) -> OutEnvelope {
debug_assert!(
kind_contract_holds(action, kind),
"kind {} not declared for action {action} — update the shared registry",
kind.as_str()
);
OutEnvelope {
action: action.to_string(),
ok,
kind,
payload,
}
}
#[must_use]
fn env_failure(action: &str, params: Value, error: &str) -> OutEnvelope {
let mut obj = match params {
Value::Object(m) => m,
other => {
let mut m = serde_json::Map::new();
m.insert("error".into(), other);
m
}
};
obj.insert("error".into(), json!(error));
out_env(action, false, OutKind::Environment, Value::Object(obj))
}
fn require_cli(action: &str, params: Value) -> Result<std::path::PathBuf, OutEnvelope> {
cli_path().ok_or_else(|| env_failure(action, params, "chrome-use CLI not found"))
}
async fn status() -> OutEnvelope {
let (chrome_use, version_ok) = match cli_version().await {
Some(v) => (v.to_string(), true),
None => match cli_path() {
None => ("missing".to_string(), false),
Some(_) => match cli_probe().await {
CliStatus::Available => ("present".to_string(), true),
CliStatus::Missing => ("missing".to_string(), false),
CliStatus::Transient(f) => (f.to_string(), false),
},
},
};
let readiness = readiness().await;
let mut payload = serde_json::Map::new();
payload.insert("chrome_use".into(), json!(chrome_use));
payload.insert("relay_up".into(), json!(readiness.relay_up));
payload.insert("chrome_running".into(), json!(readiness.chrome_running));
payload.insert("display".into(), json!(readiness.display));
payload.insert(
"ready_for_actions".into(),
json!(readiness.ready_for_actions()),
);
payload.insert(
"verdict".into(),
json!(if readiness.ready_for_actions() {
"ready"
} else if readiness.blocked() {
"blocked"
} else {
"not-proven"
}),
);
payload.insert("readiness".into(), json!(readiness.report()));
if readiness.ready_for_actions() {
return out_env("status", true, OutKind::Ok, Value::Object(payload));
}
let error = if !version_ok {
Some(format!(
"chrome-use CLI: {chrome_use}. {}",
readiness.refusal()
))
} else if readiness.blocked() {
Some(readiness.refusal())
} else {
None
};
let Some(error) = error else {
return out_env("status", true, OutKind::Ok, Value::Object(payload));
};
eprintln!("mahbot chrome: {error}");
payload.insert("error".into(), json!(error));
out_env(
"status",
false,
OutKind::Environment,
Value::Object(payload),
)
}
fn network_failure_error(code: Option<&str>) -> String {
match code {
Some(c) => {
format!(
"Chrome rendered an error page — {c} ({})",
net_error_phrase(c)
)
}
None => "Chrome rendered an error page (site unreachable or refused)".to_string(),
}
}
fn deadline_error(chrome_side: Duration, kill: Duration, what: &str) -> String {
let kill = kill.as_millis();
if kill > chrome_side.as_millis() {
format!(
"deadline reached after {kill}ms — {what}: that is the product's own bound, which \
rode above the {}ms deadline chrome-use itself was working to, so chrome-use never \
reported its own reason",
chrome_side.as_millis()
)
} else {
format!(
"deadline reached after {kill}ms — {what}: that is the product's own bound for the \
step (chrome-use was given {}ms), so a product-side bound — not chrome-use's \
verdict — ended the call",
chrome_side.as_millis()
)
}
}
const SETTLE_CAP: Duration = Duration::from_secs(10);
const CAPTURE_RESERVE: Duration = Duration::from_millis(2500);
const MIN_SETTLE_BUDGET: Duration = Duration::from_secs(1);
fn settle_budget(remaining: Duration) -> Option<Duration> {
let budget = remaining.saturating_sub(CAPTURE_RESERVE).min(SETTLE_CAP);
(budget >= MIN_SETTLE_BUDGET).then_some(budget)
}
fn open_args(url: &str, expect: Option<&str>) -> Result<Option<WaitTarget>, OutEnvelope> {
if let Err(e) = validate_url(url) {
return Err(out_env(
"open",
false,
OutKind::Usage,
json!({ "url": url, "error": e.to_string() }),
));
}
expect
.map(|sel| wait_target(Some(sel), None, None))
.transpose()
.map_err(|e| {
out_env(
"open",
false,
OutKind::Usage,
json!({ "url": url, "error": e }),
)
})
}
#[expect(clippy::too_many_lines)]
async fn open(
url: &str,
expect: Option<&str>,
structural: bool,
timeout: Duration,
session: &str,
) -> OutEnvelope {
let wait_for = match open_args(url, expect) {
Ok(wait_for) => wait_for,
Err(refusal) => return refusal,
};
let path = match require_cli("open", json!({ "url": url })) {
Ok(p) => p,
Err(e) => return e,
};
let started = Instant::now();
let total = timeout + KILL_SLACK;
let operation = StepClocks::operation();
let resp = match spawn_step(&path, &["open", url], Some(session), operation, None).await {
Ok(resp) => resp,
Err(f) => return f.envelope("open", json!({ "url": url }), operation),
};
let final_url = resp
.data
.as_ref()
.and_then(|d| d.get("url"))
.and_then(Value::as_str)
.unwrap_or(url)
.to_string();
if is_blank_page_url(&final_url) {
return out_env(
"open",
false,
OutKind::Network,
json!({
"url": url,
"error": "navigation never committed — tab still on about:blank"
}),
);
}
let probe_budget = total.saturating_sub(started.elapsed()).min(KILL_SLACK);
if probe_budget >= Duration::from_millis(500)
&& let Ok(probe) = spawn_step(
&path,
&["eval", ERROR_PAGE_PROBE_JS],
Some(session),
StepClocks::bounded(CHROME_USE_DECLARED_BUDGET, probe_budget),
None,
)
.await
&& let Some(ErrorPageProbe {
is_error_page: true,
code,
}) = parse_error_page_probe(&probe)
{
let mut payload = json!({
"url": url,
"error": network_failure_error(code.as_deref()),
});
if let Some(code) = code {
payload["error_code"] = json!(code);
}
return out_env("open", false, OutKind::Network, payload);
}
if let Some(target) = wait_for {
let target_desc = target.describe();
let mut payload = json!({ "url": final_url, "target": target_desc });
let (timeout_kind, hint) = if structural {
(
OutKind::Redesign,
"possible DOM redesign or structural change",
)
} else {
(
OutKind::Timeout,
"selector not found — may be structural change, empty region, or content-dependent",
)
};
let wait_budget = timeout.saturating_sub(started.elapsed());
if wait_budget < Duration::from_millis(250) {
payload["timeout_ms"] = json!(timeout.as_millis());
payload["error"] = json!(format!(
"no budget left for the `--expect` wait: the operation's own --timeout budget \
({}ms) was spent by the navigation and the steps around it before the wait \
could be given any of it, so chrome-use was never given a deadline for that \
step — this is the product's own bound on `open`, not chrome-use's verdict. \
Raise --timeout or retry now that the page is open.",
timeout.as_millis()
));
payload["hint"] = json!(hint);
return out_env("open", false, timeout_kind, payload);
}
let wait_clocks = StepClocks::forwarded(wait_budget);
let wargs = wait_args(&target, wait_budget.as_millis());
let refs: Vec<&str> = wargs.iter().map(String::as_str).collect();
let waited = spawn_step(&path, &refs, Some(session), wait_clocks, None).await;
let content =
capture_open_content(&path, session, total.saturating_sub(started.elapsed())).await;
if let Some(content) = content {
payload["content"] = json!(content);
}
return match waited {
Ok(_) => out_env("open", true, OutKind::Ok, payload),
Err(f) if f.kind == OutKind::Timeout => {
let reported = if f.message.is_empty() {
wait_clocks.call.kill
} else {
wait_clocks.call.chrome_side
};
payload["timeout_ms"] = json!(reported.as_millis());
if f.message.is_empty() {
payload["error"] = json!(deadline_error(
wait_clocks.call.chrome_side,
wait_clocks.call.kill,
"the target never appeared"
));
} else {
payload["error"] = json!(f.message);
}
payload["hint"] = json!(hint);
out_env("open", false, timeout_kind, payload)
}
Err(f) => f.envelope("open", payload, wait_clocks),
};
}
if let Some(budget) = settle_budget(total.saturating_sub(started.elapsed())) {
let _ = spawn_step(
&path,
&["wait", "--load", "networkidle"],
Some(session),
StepClocks::bounded(budget, budget),
None,
)
.await;
}
let mut payload = json!({ "url": final_url });
let budget = total.saturating_sub(started.elapsed());
if let Some(content) = capture_open_content(&path, session, budget).await {
payload["content"] = json!(content);
}
out_env("open", true, OutKind::Ok, payload)
}
async fn capture_open_content(path: &Path, session: &str, budget: Duration) -> Option<String> {
if budget < Duration::from_millis(500) {
return None;
}
let resp = spawn_step(
path,
&["snapshot", "-c"],
Some(session),
StepClocks::bounded(CHROME_USE_DECLARED_BUDGET, budget),
None,
)
.await
.ok()?;
let text = resp.data.as_ref().and_then(extract_snapshot_text)?;
let text = text.trim();
if text.is_empty() {
return None;
}
Some(truncate_sandwich(
text,
TOOL_OUTPUT_BUDGET_BYTES,
"page content",
))
}
async fn count_via_eval(
path: &Path,
selector: &str,
session: &str,
clocks: StepClocks,
) -> Result<u64, StepFailure> {
let js = count_eval_js(selector);
let args = ["eval".to_string(), js];
let refs: Vec<&str> = args.iter().map(String::as_str).collect();
let resp = spawn_step(path, &refs, Some(session), clocks, None).await?;
eval_count(&resp).ok_or(StepFailure {
kind: OutKind::Error,
message: "count eval returned a non-numeric result".to_string(),
})
}
async fn count(selector: &str, bound: Option<Duration>, session: &str) -> OutEnvelope {
let path = match require_cli("count", json!({ "selector": selector })) {
Ok(p) => p,
Err(e) => return e,
};
let clocks = StepClocks::tool_clock(bound);
match count_via_eval(&path, selector, session, clocks).await {
Ok(n) => {
let kind = if n == 0 { OutKind::Empty } else { OutKind::Ok };
out_env(
"count",
true,
kind,
json!({ "selector": selector, "count": n }),
)
}
Err(f) => f.envelope("count", json!({ "selector": selector }), clocks),
}
}
async fn wait(target: &WaitTarget, timeout: Duration, session: &str) -> OutEnvelope {
let base = json!({ "target": target.describe() });
let path = match require_cli("wait", base.clone()) {
Ok(p) => p,
Err(e) => return e,
};
let clocks = StepClocks::forwarded(timeout);
let args = wait_args(target, timeout.as_millis());
let refs: Vec<&str> = args.iter().map(String::as_str).collect();
match spawn_step(&path, &refs, Some(session), clocks, None).await {
Ok(_) => out_env(
"wait",
true,
OutKind::Ok,
json!({ "target": target.describe() }),
),
Err(mut f) => {
with_condition_timeout_note("wait", f.kind, &mut f.message);
f.envelope("wait", base, clocks)
}
}
}
fn expect_envelope(condition: &str, outcome: ExpectOutcome) -> OutEnvelope {
let mut payload = json!({ "condition": condition });
if let Some(actual) = outcome.actual {
payload["actual"] = actual;
}
if outcome.pass {
payload["pass"] = json!(true);
return out_env("expect", true, OutKind::Ok, payload);
}
payload["pass"] = json!(false);
let kind = if outcome.timed_out {
payload["timed_out"] = json!(true);
payload["error"] = json!(format!(
"condition was not met within the deadline{EXPECT_TIMEOUT_NOTE}"
));
OutKind::Timeout
} else {
payload["error"] = json!("condition is false");
OutKind::Error
};
out_env("expect", false, kind, payload)
}
async fn expect(cond: &ExpectCond, timeout: Duration, session: &str) -> OutEnvelope {
let condition = describe(cond);
let base = json!({ "condition": condition });
let path = match require_cli("expect", base.clone()) {
Ok(p) => p,
Err(e) => return e,
};
let clocks = StepClocks::forwarded(timeout);
let args = expect_args(cond, timeout.as_millis());
let refs: Vec<&str> = args.iter().map(String::as_str).collect();
match spawn_step(&path, &refs, Some(session), clocks, None).await {
Ok(resp) => match resp.data.as_ref().and_then(expect_outcome) {
Some(outcome) => expect_envelope(&condition, outcome),
None => out_env(
"expect",
false,
OutKind::Error,
json!({ "condition": condition, "error": "expect returned an unrecognized payload" }),
),
},
Err(mut f) => {
with_condition_timeout_note("expect", f.kind, &mut f.message);
f.envelope("expect", base, clocks)
}
}
}
async fn eval(js: &str, bound: Option<Duration>, session: &str) -> OutEnvelope {
let path = match require_cli("eval", json!({ "js": js })) {
Ok(p) => p,
Err(e) => return e,
};
let clocks = StepClocks::tool_clock(bound);
match spawn_step(&path, &["eval", js], Some(session), clocks, None).await {
Ok(resp) => {
let result = eval_result(&resp).cloned().unwrap_or(Value::Null);
out_env("eval", true, OutKind::Ok, json!({ "result": result }))
}
Err(f) => f.envelope("eval", json!({ "js": js }), clocks),
}
}
async fn extract(
schema_file: &str,
limit: Option<usize>,
bound: Option<Duration>,
session: &str,
) -> OutEnvelope {
let schema = match std::fs::read_to_string(schema_file) {
Ok(s) => match serde_json::from_str::<Value>(&s) {
Ok(v) => v,
Err(e) => {
return out_env(
"extract",
false,
OutKind::Usage,
json!({ "error": format!("schema-file is not valid JSON: {e}") }),
);
}
},
Err(e) => {
return out_env(
"extract",
false,
OutKind::Usage,
json!({ "error": format!("cannot read --schema-file: {e}") }),
);
}
};
if let Err(err) = validate_extract_getters(&schema) {
return out_env("extract", false, OutKind::Usage, json!({ "error": err }));
}
let path = match require_cli("extract", json!({})) {
Ok(p) => p,
Err(e) => return e,
};
let clocks = StepClocks::tool_clock(bound);
if let Some(rows_sel) = schema.get("rows").and_then(Value::as_str) {
let count = count_via_eval(&path, rows_sel, session, clocks).await;
match extract_gate(count) {
ExtractGate::Empty => {
return out_env(
"extract",
true,
OutKind::Empty,
extract_output(&json!([]), limit),
);
}
ExtractGate::Proceed => {}
ExtractGate::Fail(f) => return f.envelope("extract", json!({}), clocks),
}
}
let args = [
"extract".to_string(),
"--schema-file".to_string(),
schema_file.to_string(),
];
let refs: Vec<&str> = args.iter().map(String::as_str).collect();
match spawn_step(&path, &refs, Some(session), clocks, None).await {
Ok(resp) => out_env(
"extract",
true,
OutKind::Ok,
extract_output(resp.data.as_ref().unwrap_or(&Value::Null), limit),
),
Err(f) => f.envelope("extract", json!({}), clocks),
}
}
async fn click(
selector: &str,
if_present: bool,
bound: Option<Duration>,
session: &str,
) -> OutEnvelope {
let path = match require_cli("click", json!({ "selector": selector })) {
Ok(p) => p,
Err(e) => return e,
};
let mut args = vec!["click".to_string(), selector.to_string()];
if if_present {
args.push("--if-present".to_string());
}
let refs: Vec<&str> = args.iter().map(String::as_str).collect();
let clocks = StepClocks::tool_clock(bound);
match spawn_step(&path, &refs, Some(session), clocks, None).await {
Ok(_) => out_env("click", true, OutKind::Ok, json!({ "selector": selector })),
Err(f) => f.envelope("click", json!({ "selector": selector }), clocks),
}
}
const STDIN_TEXT_CAP: usize = 10 * 1024 * 1024;
async fn read_stdin_capped() -> Result<Vec<u8>, String> {
use tokio::io::AsyncReadExt;
let mut buf = Vec::new();
let mut stdin = tokio::io::stdin().take((STDIN_TEXT_CAP + 1) as u64);
stdin
.read_to_end(&mut buf)
.await
.map_err(|e| format!("cannot read stdin: {e}"))?;
if buf.len() > STDIN_TEXT_CAP {
return Err(format!(
"stdin text exceeds the {STDIN_TEXT_CAP} byte cap — write it to a file and use --file"
));
}
if buf.is_empty() {
return Err(
"no text on stdin — pipe it, e.g. `cat post.md | mahbot chrome fill \".editor\" --stdin`"
.to_string(),
);
}
Ok(buf)
}
fn text_input_ok_envelope(action: &str, base: &Value, resp: ChromeResponse) -> OutEnvelope {
let warning = chrome_use_warning(&resp);
let mut payload = base.as_object().cloned().unwrap_or_default();
if let Some(d) = resp.data {
payload.insert("data".into(), d);
}
if let Some(warning) = warning {
payload.insert("warning".into(), warning);
return out_env(action, false, OutKind::Error, Value::Object(payload));
}
out_env(action, true, OutKind::Ok, Value::Object(payload))
}
async fn fill(
selector: &str,
source: &TextInput,
bound: Option<Duration>,
session: &str,
) -> OutEnvelope {
let action = "fill";
let base = json!({ "selector": selector });
let path = match require_cli(action, base.clone()) {
Ok(p) => p,
Err(e) => return e,
};
let mut argv = vec!["fill".to_string(), selector.to_string()];
argv.extend(source.argv());
let input = match source {
TextInput::Stdin => match read_stdin_capped().await {
Ok(bytes) => Some(bytes),
Err(e) => {
return out_env(
action,
false,
OutKind::Usage,
json!({ "selector": selector, "error": e }),
);
}
},
TextInput::File(f) => {
if !std::path::Path::new(f).is_file() {
return out_env(
action,
false,
OutKind::Usage,
json!({ "selector": selector, "error": format!("--file does not exist: {f}") }),
);
}
None
}
TextInput::Inline(_) => None,
};
let refs: Vec<&str> = argv.iter().map(String::as_str).collect();
let clocks = StepClocks::tool_clock(bound);
match spawn_step(&path, &refs, Some(session), clocks, input.as_deref()).await {
Ok(resp) => text_input_ok_envelope(action, &base, resp),
Err(f) => f.envelope(action, base, clocks),
}
}
async fn r#type(
selector: &str,
text: &str,
key_events: bool,
bound: Option<Duration>,
session: &str,
) -> OutEnvelope {
let action = "type";
let base = json!({ "selector": selector, "text": text });
let path = match require_cli(action, base.clone()) {
Ok(p) => p,
Err(e) => return e,
};
let mut argv = vec!["type".to_string(), selector.to_string()];
if key_events {
argv.push("--key-events".to_string());
}
argv.extend(text_value_argv(text));
let refs: Vec<&str> = argv.iter().map(String::as_str).collect();
let clocks = StepClocks::tool_clock(bound);
match spawn_step(&path, &refs, Some(session), clocks, None).await {
Ok(resp) => text_input_ok_envelope(action, &base, resp),
Err(f) => f.envelope(action, base, clocks),
}
}
async fn press(
key: &str,
selector: Option<&str>,
hold: Option<u64>,
bound: Option<Duration>,
session: &str,
) -> OutEnvelope {
let action = "press";
let mut base_obj = serde_json::Map::new();
base_obj.insert("key".into(), json!(key));
if let Some(sel) = selector {
base_obj.insert("selector".into(), json!(sel));
}
if let Some(h) = hold {
base_obj.insert("hold_ms".into(), json!(h));
}
let base = Value::Object(base_obj);
let path = match require_cli(action, base.clone()) {
Ok(p) => p,
Err(e) => return e,
};
let mut argv = vec!["press".to_string(), key.to_string()];
if let Some(sel) = selector {
argv.extend(["--selector".to_string(), sel.to_string()]);
}
if let Some(h) = hold {
argv.extend(["--hold".to_string(), h.to_string()]);
}
let refs: Vec<&str> = argv.iter().map(String::as_str).collect();
let clocks = StepClocks::tool_clock(bound);
match spawn_step(&path, &refs, Some(session), clocks, None).await {
Ok(resp) => text_input_ok_envelope(action, &base, resp),
Err(f) => f.envelope(action, base, clocks),
}
}
async fn session_stop(name: &str, force: bool) -> OutEnvelope {
let target = match resolve_stop_target(name, force) {
Ok(t) => t,
Err(error) => {
eprintln!("mahbot chrome: {error}");
return out_env(
"session",
false,
OutKind::Usage,
json!({ "session": name, "error": error }),
);
}
};
let path = match require_cli("session", json!({ "session": target })) {
Ok(p) => p,
Err(e) => return e,
};
let clocks = StepClocks::probe(SESSION_STOP_TIMEOUT);
match spawn_step(&path, &["session", "stop", &target], None, clocks, None).await {
Ok(_) => out_env("session", true, OutKind::Ok, json!({ "session": target })),
Err(f) => f.envelope("session", json!({ "session": target }), clocks),
}
}
async fn session_status(name: &str) -> OutEnvelope {
let target = resolve_session_target(name);
let path = match require_cli("session", json!({ "session": target })) {
Ok(p) => p,
Err(e) => return e,
};
let enumerate = StepClocks::probe(SESSION_LIST_TIMEOUT);
let list = match spawn_step(&path, &["session", "list"], None, enumerate, None).await {
Ok(resp) => resp,
Err(f) => {
return f.envelope(
"session",
json!({ "session": target, "stage": "enumerate" }),
enumerate,
);
}
};
if !session_listed(&list, &target) {
return out_env(
"session",
true,
OutKind::Empty,
json!({ "session": target, "detail": "session is not running — nothing to probe" }),
);
}
let probe = StepClocks::probe(SESSION_PROBE_TIMEOUT);
match spawn_step(&path, &["get", "url"], Some(&target), probe, None).await {
Ok(resp) => {
let url = resp
.data
.as_ref()
.and_then(|d| d.get("url"))
.and_then(Value::as_str)
.unwrap_or("?");
out_env(
"session",
true,
OutKind::Ok,
json!({ "session": target, "status": "usable", "url": url }),
)
}
Err(mut f) => {
if f.kind == OutKind::Timeout {
f = StepFailure {
kind: OutKind::Environment,
message: format!(
"session unresponsive: the session stopped answering — no answer to the \
liveness probe (get url) within {}s. This diagnostic stops nothing; \
recover it with {SESSION_RECOVERY_FLOW}, or let the next action verb \
recover it automatically.",
SESSION_PROBE_TIMEOUT.as_secs()
),
};
}
append_protected_stop_note(&mut f.message, &target);
f.envelope("session", json!({ "session": target }), probe)
}
}
}
fn append_protected_stop_note(message: &mut String, target: &str) {
if PROTECTED_SESSION_PREFIXES
.iter()
.any(|p| target.starts_with(p))
&& is_session_unresponsive_error(message)
{
write!(
message,
" (protected namespace: stopping '{target}' requires `session stop {target} --force`)"
)
.expect("writing to a String cannot fail");
}
}
fn session_listed(resp: &ChromeResponse, target: &str) -> bool {
if resp.verdict() == Some(false) {
return false;
}
let names = resp
.extra
.get("sessions")
.or_else(|| resp.data.as_ref().and_then(|d| d.get("sessions")));
let Some(Value::Array(arr)) = names else {
return false;
};
arr.iter().any(|entry| match entry {
Value::String(s) => s == target,
Value::Object(_) => entry.get("name").and_then(Value::as_str) == Some(target),
_ => false,
})
}
async fn close_ephemeral(name: &str) {
let Some(path) = cli_path() else {
eprintln!(
"mahbot chrome: could not close ephemeral session '{name}' (chrome-use CLI not found)"
);
return;
};
let clocks = StepClocks::probe(SESSION_STOP_TIMEOUT);
match spawn_cli(CliSpawn {
path: &path,
args: &["session", "stop", name],
session: None,
json: true,
capture_stderr: false,
cancel_kills: true,
timeout: CliTimeout::Bounded(clocks.call.kill),
input: None,
chrome_side: clocks.call.chrome_side,
recovery: clocks.recovery,
})
.await
{
CliRun::Output(out) => {
CHROME_USE_LEFTOVER_PIPES.fetch_or(out.leftover_pipes, Ordering::Relaxed);
if !out.status.success() {
eprintln!("mahbot chrome: failed to close ephemeral session '{name}'");
}
}
CliRun::SpawnFailure => {
eprintln!(
"mahbot chrome: could not spawn chrome-use to close ephemeral session '{name}'"
);
}
CliRun::TimedOut => {
eprintln!("mahbot chrome: timed out closing ephemeral session '{name}'");
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::chrome::kill_bound;
#[test]
fn eval_count_unwraps_chrome_use_result_envelope() {
let resp = |data: Value| ChromeResponse {
success: Some(true),
ok: None,
data: Some(data),
error: None,
code: None,
retryable: None,
extra: serde_json::Map::default(),
};
assert_eq!(
eval_count(&resp(json!({"origin": "https://x", "result": 7}))),
Some(7)
);
assert_eq!(eval_count(&resp(json!(3))), Some(3));
assert_eq!(
eval_count(&resp(json!({"origin": "https://x", "result": "12"}))),
Some(12)
);
assert_eq!(eval_count(&resp(json!("0"))), Some(0));
assert_eq!(
eval_count(&resp(json!({"origin": "https://x", "result": "abc"}))),
None
);
assert_eq!(eval_count(&resp(Value::Null)), None);
let wrapped = resp(json!({"origin": "https://x", "result": {"a": 1}}));
assert_eq!(eval_result(&wrapped), Some(&json!({"a": 1})));
let boolean = resp(json!({"origin": "https://x", "result": true}));
assert_eq!(eval_result(&boolean).and_then(Value::as_bool), Some(true));
let bare = json!({"result": "page payload", "other": 1});
assert_eq!(eval_result(&resp(bare.clone())), Some(&bare));
}
#[test]
fn resolve_session_named_prefixing_is_idempotent() {
let (name, ephemeral) = resolve_session(Some("foo"));
assert_eq!(name, "mahbot-chrome-foo");
assert!(!ephemeral);
let (name, ephemeral) = resolve_session(Some("mahbot-chrome-foo"));
assert_eq!(name, "mahbot-chrome-foo");
assert!(!ephemeral);
}
#[test]
fn resolve_session_ephemeral_uses_cli_prefix() {
let (name, ephemeral) = resolve_session(None);
assert!(name.starts_with(CLI_EPHEMERAL_PREFIX));
assert!(ephemeral);
}
#[test]
fn session_names_stay_within_the_alphabet_chrome_use_can_stop() {
for name in ["qa-2933", "qa_2933", "Qa2933"] {
assert!(
validate_session_name(name).is_ok(),
"{name} must be accepted"
);
}
for name in ["qa.2933", "qa 2933", "", "../qa", "qa/2933", "qa;stop"] {
assert!(
validate_session_name(name).is_err(),
"{name:?} must be refused — chrome-use will not stop such a session"
);
}
assert!(
parse_invocation(&[
"session".into(),
"stop".into(),
"qa.2933".into(),
"--force".into()
])
.is_err(),
"the refusal must also be wired into the stop verb, not only --session"
);
}
#[test]
fn step_clocks_ride_the_kill_above_the_clock_they_declare() {
let chrome_own = clocks(None, CliRecovery::Allowed);
let navigation = StepClocks::operation();
assert_eq!(navigation.call.chrome_side, CHROME_USE_DECLARED_BUDGET);
assert_eq!(
navigation.call.kill,
kill_bound(CHROME_USE_DECLARED_BUDGET, CliRecovery::Allowed)
);
let no_bound = StepClocks::tool_clock(None);
assert_eq!(no_bound.call.chrome_side, CHROME_USE_DECLARED_BUDGET);
assert_eq!(no_bound.call.kill, chrome_own.kill);
let at_the_clock = StepClocks::tool_clock(Some(CHROME_USE_DECLARED_BUDGET));
assert_eq!(at_the_clock.call.kill, chrome_own.kill);
let wide = StepClocks::tool_clock(Some(Duration::from_secs(300)));
assert_eq!(wide.call.chrome_side, CHROME_USE_DECLARED_BUDGET);
assert_eq!(
wide.call.kill,
kill_bound(Duration::from_secs(300), CliRecovery::Allowed)
);
let env = StepFailure {
kind: OutKind::Timeout,
message: String::new(),
}
.envelope("eval", json!({ "js": "1" }), wide);
assert_eq!(env.payload["timeout_ms"], json!(wide.call.kill.as_millis()));
let declared_ms = format!("{}ms", CHROME_USE_DECLARED_BUDGET.as_millis());
assert!(
env.payload["error"]
.as_str()
.is_some_and(|e| e.contains(&declared_ms)),
"the kill must ride above the clock the call really ran to: {}",
env.payload["error"]
);
let probe = StepClocks::bounded(CHROME_USE_DECLARED_BUDGET, Duration::from_secs(2));
assert_eq!(probe.call.kill, Duration::from_secs(2) + KILL_SLACK);
assert!(matches!(probe.recovery, CliRecovery::Suppressed));
let settle = StepClocks::bounded(Duration::from_secs(6), Duration::from_secs(6));
assert_eq!(settle.call.chrome_side, Duration::from_secs(6));
assert_eq!(settle.call.kill, Duration::from_secs(6) + KILL_SLACK);
let oversized = StepClocks::bounded(CHROME_USE_DECLARED_BUDGET, Duration::from_secs(600));
assert_eq!(oversized.call.kill, Duration::from_secs(600) + KILL_SLACK);
assert!(oversized.call.kill > chrome_own.kill);
}
#[test]
fn settle_budget_caps_and_reserves_capture() {
assert_eq!(settle_budget(Duration::from_secs(22)), Some(SETTLE_CAP));
assert_eq!(
settle_budget(Duration::from_secs(4)),
Some(Duration::from_millis(1500))
);
assert_eq!(
settle_budget(Duration::from_millis(3500)),
Some(MIN_SETTLE_BUDGET)
);
assert_eq!(settle_budget(Duration::from_millis(3499)), None);
assert_eq!(settle_budget(Duration::ZERO), None);
}
#[test]
fn session_stop_gating_resolves_and_protects() {
assert_eq!(
resolve_stop_target("mahbot-chrome-foo", false).unwrap(),
"mahbot-chrome-foo"
);
assert_eq!(
resolve_stop_target("foo", false).unwrap(),
"mahbot-chrome-foo"
);
assert!(resolve_stop_target("agent-tab-1", false).is_err());
assert_eq!(
resolve_stop_target("agent-tab-1", true).unwrap(),
"agent-tab-1"
);
assert!(resolve_stop_target("link-enricher-7", false).is_err());
}
#[test]
fn resolve_session_target_resolves_namespaces() {
assert_eq!(resolve_session_target("docs"), "mahbot-chrome-docs");
assert_eq!(
resolve_session_target("mahbot-chrome-foo"),
"mahbot-chrome-foo"
);
assert_eq!(resolve_session_target("agent-tab-1"), "agent-tab-1");
assert_eq!(resolve_session_target("link-enricher-7"), "link-enricher-7");
}
#[test]
fn session_status_parses_and_rejects() {
let inv = parse_invocation(&["session".into(), "status".into(), "docs".into()])
.expect("session status parses");
match inv.action {
Action::SessionStatus { name } => assert_eq!(name, "docs"),
_ => panic!("expected SessionStatus"),
}
let err = parse_invocation(&[
"session".into(),
"status".into(),
"docs".into(),
"--session".into(),
"x".into(),
])
.err()
.expect("--session must be rejected for session subcommands");
assert_eq!(err, "--session is not valid for session stop/status");
let err = parse_session(
None,
&["status".into(), "docs".into(), "--force".into()],
(&[], &["force"]),
)
.err()
.expect("--force must be rejected for session status");
assert_eq!(err, "--force is not valid for session status");
assert!(
parse_session(
None,
&["status".into(), "a".into(), "b".into()],
(&[], &["force"]),
)
.is_err()
);
let err = parse_invocation(&["session".into(), "destroy".into(), "docs".into()])
.err()
.expect("unknown sub must be rejected");
assert!(
err.contains("unknown session subcommand 'destroy' (expected 'stop' or 'status')"),
"unexpected error: {err}"
);
}
#[test]
fn session_listed_handles_both_shapes() {
let latest: ChromeResponse = serde_json::from_value(json!({
"ok": true, "sessions": [{ "name": "mahbot-chrome-docs" }]
}))
.expect("deserialize latest shape");
assert!(session_listed(&latest, "mahbot-chrome-docs"));
assert!(!session_listed(&latest, "mahbot-chrome-other"));
let legacy: ChromeResponse = serde_json::from_value(json!({
"success": true, "data": { "sessions": ["mahbot-chrome-docs"] }
}))
.expect("deserialize legacy shape");
assert!(session_listed(&legacy, "mahbot-chrome-docs"));
let failed: ChromeResponse =
serde_json::from_value(json!({ "success": false })).expect("deserialize failure");
assert!(!session_listed(&failed, "mahbot-chrome-docs"));
let no_sessions: ChromeResponse =
serde_json::from_value(json!({ "success": true, "data": {} }))
.expect("deserialize empty payload");
assert!(!session_listed(&no_sessions, "mahbot-chrome-docs"));
}
#[test]
fn protected_stop_note_targets_only_protected_wedges() {
let mut msg = "session unresponsive: no response within 45s".to_string();
append_protected_stop_note(&mut msg, "agent-tab-1");
assert!(
msg.ends_with("(protected namespace: stopping 'agent-tab-1' requires `session stop agent-tab-1 --force`)"),
"note missing: {msg}"
);
let mut msg = "session unresponsive: no response within 45s".to_string();
append_protected_stop_note(&mut msg, "mahbot-chrome-docs");
assert_eq!(msg, "session unresponsive: no response within 45s");
let mut msg = "element not found".to_string();
append_protected_stop_note(&mut msg, "agent-tab-1");
assert_eq!(msg, "element not found");
}
#[test]
fn envelope_layers_accurate_remediation() {
let env = StepFailure {
kind: OutKind::Environment,
message: "session unresponsive: no response within 45s".to_string(),
}
.envelope(
"session",
json!({ "session": "mahbot-chrome-docs" }),
StepClocks::probe(SESSION_STOP_TIMEOUT),
);
assert_eq!(
env.payload.get("error").and_then(Value::as_str),
Some("session unresponsive: no response within 45s")
);
let env = StepFailure {
kind: OutKind::Environment,
message: "relay isn't connected".to_string(),
}
.envelope(
"session",
json!({ "session": "mahbot-chrome-docs" }),
StepClocks::probe(SESSION_STOP_TIMEOUT),
);
assert_eq!(
env.payload.get("error").and_then(Value::as_str),
Some("relay isn't connected")
);
let env = StepFailure {
kind: OutKind::Environment,
message: "the tab this session was driving can no longer be resolved".to_string(),
}
.envelope(
"open",
json!({ "url": "https://x" }),
StepClocks::operation(),
);
let err = env
.payload
.get("error")
.and_then(Value::as_str)
.expect("error present");
assert!(
err.contains("close the leftover tab in Chrome"),
"got: {err}"
);
let clocks = StepClocks::probe(SESSION_PROBE_TIMEOUT);
let env = StepFailure {
kind: OutKind::Timeout,
message: String::new(),
}
.envelope(
"session",
json!({ "session": "mahbot-chrome-docs" }),
clocks,
);
let expected = deadline_error(
clocks.call.chrome_side,
clocks.call.kill,
"the step did not complete",
);
assert_eq!(
env.payload.get("error").and_then(Value::as_str),
Some(expected.as_str())
);
assert_eq!(env.payload["timeout_ms"], 20_000);
assert!(expected.contains("the product's own bound for the step"));
assert!(!expected.contains("rode above"));
}
#[test]
fn wedge_keying_uses_who_ended_the_step_not_the_kind_alone() {
let env = |kind: OutKind, error: &str| {
out_env(
"open",
false,
kind,
json!({ "url": "https://x", "error": error }),
)
};
let diagnosed = env(
OutKind::Environment,
"session unresponsive: no response within 45s",
);
assert_eq!(wedge_action(&diagnosed, true, false), WedgeAction::Recover);
let cdp = env(
OutKind::Environment,
"CDP session is unresponsive after attaching (Connection reset).",
);
assert_eq!(wedge_action(&cdp, true, false), WedgeAction::Recover);
let verdict = env(OutKind::Timeout, "Wait timed out after 15000ms");
assert_eq!(wedge_action(&verdict, true, false), WedgeAction::Leave);
let killed = env(OutKind::Timeout, "deadline reached after 92000ms");
assert_eq!(wedge_action(&killed, true, true), WedgeAction::Probe);
let redesign = env(
OutKind::Redesign,
"possible DOM redesign or structural change",
);
assert_eq!(wedge_action(&redesign, true, true), WedgeAction::Probe);
assert_eq!(wedge_action(&redesign, true, false), WedgeAction::Leave);
let relay = env(OutKind::Environment, "relay isn't connected");
assert_eq!(wedge_action(&relay, true, true), WedgeAction::Leave);
let missing = env(OutKind::Error, "element not found");
assert_eq!(wedge_action(&missing, true, false), WedgeAction::Leave);
let net = env(OutKind::Network, "net::ERR_CONNECTION_REFUSED");
assert_eq!(wedge_action(&net, true, true), WedgeAction::Leave);
assert_eq!(wedge_action(&killed, false, true), WedgeAction::Leave);
}
#[tokio::test]
async fn wedge_recovery_leaves_a_normal_timeout_alone() {
let failure =
|error: &str| out_env("expect", false, OutKind::Timeout, json!({ "error": error }));
let mut env = failure("condition not met");
recover_session_wedge(&mut env, "mahbot-chrome-docs", true, false).await;
assert_eq!(env.payload["error"], "condition not met");
let mut env = failure("condition not met");
recover_session_wedge(&mut env, "mahbot-chrome-ephemeral-x", false, true).await;
assert_eq!(env.payload["error"], "condition not met");
}
#[test]
fn wedge_recovery_note_reports_what_was_done_or_the_manual_flow() {
let stopped = wedge_recovery_note(SessionRecovery::Stopped);
assert_eq!(stopped, SessionRecovery::Stopped.summary());
assert!(!stopped.contains("recover with"), "got: {stopped}");
for recovery in [SessionRecovery::Unanswered, SessionRecovery::NotStarted] {
let note = wedge_recovery_note(recovery);
assert!(note.starts_with(recovery.summary()), "got: {note}");
assert!(note.contains(SESSION_RECOVERY_FLOW), "got: {note}");
assert!(!note.contains("could not run"), "got: {note}");
}
}
#[test]
fn append_error_note_extends_the_error_text_only() {
let timeout_error = "deadline reached after 92000ms — the step did not complete";
let mut env = out_env(
"open",
false,
OutKind::Timeout,
json!({ "url": "https://x", "error": timeout_error }),
);
append_error_note(&mut env, "what was done");
assert_eq!(
env.payload.get("error").and_then(Value::as_str),
Some(format!("{timeout_error} — what was done").as_str())
);
let mut env = out_env("open", false, OutKind::Ok, json!({ "url": "https://x" }));
append_error_note(&mut env, "what was done");
assert!(env.payload.get("error").is_none());
}
#[test]
fn classify_step_output_pins_envelope_over_exit_code() {
let envelope = |body: Value| serde_json::to_vec(&body).expect("serialize envelope");
let outcome = |r: &StepOutcome| match r {
Ok(resp) => Ok(resp.data.clone()),
Err(f) => Err((f.kind, f.message.clone())),
};
let resp = classify_step_output(
true,
false,
Some(1),
false,
&envelope(
json!({"success": true, "data": {"pass": false, "actual": 3, "timedOut": true}}),
),
"",
)
.expect("expect verdict must survive the non-zero exit");
assert_eq!(
resp.data
.as_ref()
.and_then(expect_outcome)
.map(|o| (o.pass, o.timed_out, o.actual)),
Some((false, true, Some(json!(3))))
);
let r = classify_step_output(
false,
false,
Some(1),
false,
&envelope(json!({"success": true, "data": {"pass": false}})),
"",
);
assert_eq!(
outcome(&r),
Err((OutKind::Error, "chrome-use exited with code 1".into()))
);
let r = classify_step_output(
true,
false,
Some(2),
false,
&envelope(
json!({"success": false, "error": "Browser not launched", "code": "browser_not_launched"}),
),
"",
);
assert_eq!(
outcome(&r),
Err((OutKind::Environment, "Browser not launched".into()))
);
let r = classify_step_output(
false,
false,
Some(1),
false,
&envelope(json!({"success": false, "error": "Wait timed out after 15000ms"})),
"",
);
assert_eq!(
outcome(&r),
Err((OutKind::Timeout, "Wait timed out after 15000ms".into()))
);
assert!(
outcome(&classify_step_output(
false,
false,
Some(0),
true,
&envelope(json!({"success": true, "data": {}})),
""
))
.is_ok()
);
let r = classify_step_output(false, false, Some(0), true, b"garbage", "");
assert_eq!(
outcome(&r),
Err((OutKind::Error, "chrome-use returned non-JSON output".into()))
);
let r = classify_step_output(
false,
false,
Some(1),
false,
b"garbage",
"relay is not connected",
);
assert_eq!(
outcome(&r),
Err((OutKind::Environment, "relay is not connected".into()))
);
}
#[test]
fn classify_step_output_strips_canned_relay_hint() {
let envelope = |body: Value| serde_json::to_vec(&body).expect("serialize envelope");
let outcome = |r: &StepOutcome| match r {
Ok(resp) => Ok(resp.data.clone()),
Err(f) => Err((f.kind, f.message.clone())),
};
let hint = "Hint: the session's browser connection is unresponsive (likely a stale \
relay/service-worker mid-session). Reconnect with `connect`, or close the \
session and reopen it.";
let r = classify_step_output(
false,
false,
Some(1),
false,
&envelope(
json!({"success": false, "error": format!("Wait timed out after 15000ms. {hint}")}),
),
"",
);
assert_eq!(
outcome(&r),
Err((OutKind::Timeout, "Wait timed out after 15000ms".into()))
);
let r = classify_step_output(
false,
false,
Some(1),
false,
&envelope(json!({"success": false, "error": format!("some other failure. {hint}")})),
"",
);
assert_eq!(
outcome(&r),
Err((OutKind::Error, format!("some other failure. {hint}")))
);
let r = classify_step_output(
false,
false,
Some(1),
false,
b"garbage",
&format!("Wait timed out after 15000ms. {hint}"),
);
assert_eq!(
outcome(&r),
Err((OutKind::Timeout, "Wait timed out after 15000ms".into()))
);
}
#[test]
fn classify_step_output_fails_a_self_launched_browser() {
let envelope = |body: Value| serde_json::to_vec(&body).expect("serialize envelope");
let note = "This session's previous browser is gone (its browser connection was dead) \
and a fresh one was launched for this command";
let r = classify_step_output(
false,
false,
Some(0),
true,
&envelope(json!({"success": true, "warning": note})),
"",
);
let Err(f) = r else {
panic!("a browser chrome-use started itself must not classify as success")
};
assert_eq!(f.kind, OutKind::Environment);
assert_eq!(f.message, self_launched_browser_error(note));
let r = classify_step_output(
true,
false,
Some(1),
false,
&envelope(json!({"success": true, "warning": note, "data": {"pass": true}})),
"",
);
let Err(f) = r else {
panic!("a browser chrome-use started itself must not classify as success")
};
assert_eq!(f.kind, OutKind::Environment);
let r = classify_step_output(
false,
false,
Some(0),
true,
&envelope(
json!({"success": true, "warning": "no key listeners on the focused element"}),
),
"",
);
assert!(r.is_ok());
}
#[test]
fn classify_step_output_names_a_truncated_answer() {
let outcome = |r: &StepOutcome| match r {
Ok(resp) => Ok(resp.data.clone()),
Err(f) => Err((f.kind, f.message.clone())),
};
let r = classify_step_output(false, true, Some(0), true, b"garbage", "");
assert_eq!(outcome(&r), Err((OutKind::Error, truncated_output_error())));
let r = classify_step_output(false, false, Some(0), true, b"garbage", "");
assert_eq!(
outcome(&r),
Err((OutKind::Error, "chrome-use returned non-JSON output".into()))
);
let r = classify_step_output(false, true, Some(1), false, b"garbage", "some stderr");
assert_eq!(outcome(&r), Err((OutKind::Error, truncated_output_error())));
let r = classify_step_output(false, false, Some(1), false, b"garbage", "some stderr");
assert_eq!(outcome(&r), Err((OutKind::Error, "some stderr".into())));
}
#[test]
fn network_failure_error_names_code_or_falls_back() {
assert_eq!(
network_failure_error(Some("ERR_NAME_NOT_RESOLVED")),
"Chrome rendered an error page — ERR_NAME_NOT_RESOLVED (DNS resolution failure)"
);
assert_eq!(
network_failure_error(None),
"Chrome rendered an error page (site unreachable or refused)"
);
}
#[test]
fn step_failure_envelope_reports_timeout_vs_error() {
let clocks = StepClocks::forwarded(Duration::from_secs(10));
let env = StepFailure {
kind: OutKind::Timeout,
message: String::new(),
}
.envelope("wait", json!({ "selector": ".x" }), clocks);
assert_eq!(
env.payload["timeout_ms"],
json!(clocks.call.kill.as_millis())
);
let kill = kill_bound(Duration::from_secs(10), CliRecovery::Allowed).as_millis();
assert_eq!(
env.payload["error"],
format!(
"deadline reached after {kill}ms — the step did not complete: that is the \
product's own bound, which rode above the 10000ms deadline chrome-use itself \
was working to, so chrome-use never reported its own reason"
)
);
let env = StepFailure {
kind: OutKind::Network,
message: "net::ERR_NAME_NOT_RESOLVED".into(),
}
.envelope(
"open",
json!({ "url": "https://x" }),
StepClocks::operation(),
);
assert_eq!(env.payload["error"], "net::ERR_NAME_NOT_RESOLVED");
assert_eq!(env.payload["error_code"], "ERR_NAME_NOT_RESOLVED");
assert!(env.payload.get("timeout_ms").is_none());
let env = StepFailure {
kind: OutKind::Network,
message: "connection refused by peer".into(),
}
.envelope(
"open",
json!({ "url": "https://x" }),
StepClocks::operation(),
);
assert!(env.payload.get("error_code").is_none());
}
#[test]
fn expect_envelope_maps_pass_timeout_and_false() {
let env = expect_envelope(
"'#main' is visible",
ExpectOutcome {
pass: true,
actual: Some(json!({"tag": "h1"})),
timed_out: false,
},
);
assert!(env.ok);
assert_eq!(env.kind, OutKind::Ok);
assert_eq!(env.payload["pass"], json!(true));
assert_eq!(env.payload["actual"], json!({"tag": "h1"}));
let env = expect_envelope(
"count('.card') == 3",
ExpectOutcome {
pass: false,
actual: Some(json!(0)),
timed_out: true,
},
);
assert!(!env.ok);
assert_eq!(env.kind, OutKind::Timeout);
assert_eq!(env.payload["pass"], json!(false));
assert_eq!(env.payload["timed_out"], json!(true));
assert_eq!(
env.payload["error"],
json!(
"condition was not met within the deadline — consider verifying the condition or allowing more time"
)
);
let env = expect_envelope(
"url contains \"dashboard\"",
ExpectOutcome {
pass: false,
actual: Some(json!("about:blank")),
timed_out: false,
},
);
assert!(!env.ok);
assert_eq!(env.kind, OutKind::Error);
assert_eq!(env.payload["pass"], json!(false));
assert!(env.payload.get("timed_out").is_none());
let env = expect_envelope(
"'#main' is present",
ExpectOutcome {
pass: true,
actual: None,
timed_out: false,
},
);
assert!(env.ok);
assert_eq!(env.kind, OutKind::Ok);
assert_eq!(env.payload["pass"], json!(true));
assert!(env.payload.get("actual").is_none());
}
#[test]
fn timeout_flag_is_capped_below_chrome_use_client_tolerance() {
let err = parse_invocation(&["wait".into(), "#x".into(), "--timeout".into(), "45".into()])
.err()
.expect("a deadline at the tolerance must be refused");
assert!(
err.contains("45") && err.contains("client tolerance"),
"err must be the tolerance refusal, naming the value: {err}"
);
assert!(
parse_invocation(&["wait".into(), "#x".into(), "--timeout".into(), "44".into(),])
.is_ok(),
"one second below the tolerance must parse"
);
for args in [
&["expect".into(), "#x".into(), "visible".into()][..],
&["open".into(), "https://example.com".into()][..],
] {
let mut argv = args.to_vec();
argv.extend(["--timeout".into(), "45".into()]);
let err = parse_invocation(&argv)
.err()
.unwrap_or_else(|| panic!("{argv:?} must refuse a deadline at the tolerance"));
assert!(
err.contains("45") && err.contains("client tolerance"),
"{argv:?} must be refused on the value: {err}"
);
}
let declared = CHROME_USE_DECLARED_BUDGET.as_secs().to_string();
for action in [
&["count".into(), ".x".into()][..],
&["eval".into(), "1+1".into()][..],
&["extract".into(), "--schema-file".into(), "s.json".into()][..],
&["click".into(), "#a".into()][..],
&["fill".into(), "#a".into(), "t".into()][..],
&["type".into(), "#a".into(), "t".into()][..],
&["press".into(), "Enter".into()][..],
] {
let mut argv = action.to_vec();
argv.extend(["--timeout".into(), "5".into()]);
let err = parse_invocation(&argv)
.err()
.unwrap_or_else(|| panic!("{argv:?} must refuse an unhonourable bound"));
assert!(
err.contains("--timeout 5s") && err.contains(&declared),
"{argv:?} must name the refused value and the shortest honoured one: {err}"
);
}
let at_the_clock = parse_invocation(&[
"eval".into(),
"1+1".into(),
"--timeout".into(),
declared.clone(),
])
.expect("a bound at the declared clock is honoured");
assert!(matches!(
at_the_clock.action,
Action::Eval { bound: Some(b), .. } if b == CHROME_USE_DECLARED_BUDGET
));
let wide = parse_invocation(&[
"eval".into(),
"1+1".into(),
"--timeout".into(),
"300".into(),
])
.expect("a verb that forwards no deadline takes any bound");
match wide.action {
Action::Eval { bound, .. } => {
assert_eq!(bound, Some(Duration::from_secs(300)));
}
_ => panic!("expected Eval with the user's bound"),
}
}
#[expect(clippy::too_many_lines)]
#[test]
fn parse_happy_paths_support_both_flag_forms() {
assert!(matches!(
parse_invocation(&["status".into()])
.expect("status parses")
.action,
Action::Status
));
let inv = parse_invocation(&[
"open".into(),
"https://example.com".into(),
"--expect".into(),
".btn".into(),
"--structural".into(),
"--timeout".into(),
"5".into(),
])
.expect("open parses");
match inv.action {
Action::Open {
url,
expect,
structural,
timeout,
} => {
assert_eq!(url, "https://example.com");
assert_eq!(expect.as_deref(), Some(".btn"));
assert!(structural);
assert_eq!(timeout, Duration::from_secs(5));
}
_ => panic!("expected Open"),
}
let inv = parse_invocation(&[
"open".into(),
"https://example.com".into(),
"--expect=.btn".into(),
"--timeout=3".into(),
])
.expect("open parses");
match inv.action {
Action::Open {
expect, timeout, ..
} => {
assert_eq!(expect.as_deref(), Some(".btn"));
assert_eq!(timeout, Duration::from_secs(3));
}
_ => panic!("expected Open"),
}
let inv =
parse_invocation(&["open".into(), "https://example.com".into()]).expect("open parses");
match inv.action {
Action::Open { timeout, .. } => assert_eq!(timeout, DEFAULT_OPEN_TIMEOUT),
_ => panic!("expected Open"),
}
for (action, sel) in [("count", ".x"), ("eval", "1+1")] {
let inv = parse_invocation(&[action.into(), sel.into(), "--timeout=60".into()])
.expect("action parses");
match inv.action {
Action::Count { bound, .. } | Action::Eval { bound, .. } => {
assert_eq!(bound, Some(Duration::from_secs(60)));
}
_ => panic!("expected a bounded action"),
}
}
let inv = parse_invocation(&["count".into(), ".x".into()]).expect("count parses");
assert!(matches!(inv.action, Action::Count { bound: None, .. }));
let inv = parse_invocation(&["eval".into(), "1+1".into()]).expect("eval parses");
assert!(matches!(inv.action, Action::Eval { bound: None, .. }));
let inv = parse_invocation(&["wait".into(), ".x".into()]).expect("wait parses");
match inv.action {
Action::Wait { timeout, .. } => assert_eq!(timeout, DEFAULT_STEP_TIMEOUT),
_ => panic!("expected Wait"),
}
let inv = parse_invocation(&[
"wait".into(),
"--url".into(),
"dashboard".into(),
"--timeout=9".into(),
])
.expect("wait url parses");
match inv.action {
Action::Wait { target, timeout } => {
assert!(matches!(target, WaitTarget::Url(u) if u == "dashboard"));
assert_eq!(timeout, Duration::from_secs(9));
}
_ => panic!("expected Wait"),
}
let inv = parse_invocation(&["wait".into(), "--text".into(), "Loaded".into()])
.expect("wait text parses");
match inv.action {
Action::Wait { target, timeout } => {
assert!(matches!(target, WaitTarget::Text(t) if t == "Loaded"));
assert_eq!(timeout, DEFAULT_STEP_TIMEOUT);
}
_ => panic!("expected Wait"),
}
let inv = parse_invocation(&["expect".into(), "#main".into(), "visible".into()])
.expect("expect state parses");
match inv.action {
Action::Expect { cond, timeout } => {
assert!(matches!(
cond,
ExpectCond::State { selector, state } if selector == "#main" && state == "visible"
));
assert_eq!(timeout, DEFAULT_STEP_TIMEOUT);
}
_ => panic!("expected Expect"),
}
let inv = parse_invocation(&[
"expect".into(),
"count".into(),
".card".into(),
">=".into(),
"3".into(),
"--timeout=5".into(),
])
.expect("expect count parses");
match inv.action {
Action::Expect { cond, timeout } => {
assert!(matches!(
cond,
ExpectCond::Count { selector, op, n } if selector == ".card" && op == ">=" && n == 3
));
assert_eq!(timeout, Duration::from_secs(5));
}
_ => panic!("expected Expect"),
}
let inv = parse_invocation(&[
"expect".into(),
"text".into(),
"h1".into(),
"contains".into(),
"Hello".into(),
"World".into(),
])
.expect("expect text parses");
match inv.action {
Action::Expect { cond, timeout } => {
assert!(matches!(
cond,
ExpectCond::Text { selector, predicate, value }
if selector == "h1" && predicate == "contains" && value == "Hello World"
));
assert_eq!(timeout, DEFAULT_STEP_TIMEOUT);
}
_ => panic!("expected Expect"),
}
let inv = parse_invocation(&[
"expect".into(),
"url".into(),
"contains".into(),
"dashboard".into(),
])
.expect("expect url parses");
match inv.action {
Action::Expect { cond, timeout } => {
assert!(matches!(
cond,
ExpectCond::Url { predicate, pattern }
if predicate == "contains" && pattern == "dashboard"
));
assert_eq!(timeout, DEFAULT_STEP_TIMEOUT);
}
_ => panic!("expected Expect"),
}
let inv = parse_invocation(&[
"extract".into(),
"--schema-file=rows.json".into(),
"--limit=3".into(),
])
.expect("extract parses");
match inv.action {
Action::Extract {
schema_file,
limit,
bound,
} => {
assert_eq!(schema_file, "rows.json");
assert_eq!(limit, Some(3));
assert_eq!(bound, None);
}
_ => panic!("expected Extract"),
}
let inv = parse_invocation(&["click".into(), ".btn".into(), "--if-present".into()])
.expect("click parses");
match inv.action {
Action::Click {
selector,
if_present,
..
} => {
assert_eq!(selector, ".btn");
assert!(if_present);
}
_ => panic!("expected Click"),
}
let inv = parse_invocation(&[
"--session".into(),
"run-1".into(),
"count".into(),
".x".into(),
])
.expect("session parses");
assert_eq!(inv.session.as_deref(), Some("run-1"));
assert!(matches!(inv.action, Action::Count { .. }));
let inv = parse_invocation(&[
"session".into(),
"stop".into(),
"foo".into(),
"--force".into(),
])
.expect("session stop parses");
match inv.action {
Action::SessionStop { name, force } => {
assert_eq!(name, "foo");
assert!(force);
}
_ => panic!("expected SessionStop"),
}
}
#[test]
fn parse_text_input_happy_paths() {
let inv = parse_invocation(&["fill".into(), "#q".into(), "hello".into(), "world".into()])
.expect("fill inline parses");
match inv.action {
Action::Fill {
selector, source, ..
} => {
assert_eq!(selector, "#q");
assert!(matches!(source, TextInput::Inline(t) if t == "hello world"));
}
_ => panic!("expected Fill"),
}
let inv = parse_invocation(&["fill".into(), "#q".into(), "--file=post.md".into()])
.expect("fill --file parses");
match inv.action {
Action::Fill { source, .. } => {
assert!(matches!(source, TextInput::File(p) if p == "post.md"));
}
_ => panic!("expected Fill"),
}
let inv = parse_invocation(&[
"fill".into(),
"#q".into(),
"--file".into(),
"post.md".into(),
])
.expect("fill --file value parses");
match inv.action {
Action::Fill { source, .. } => {
assert!(matches!(source, TextInput::File(p) if p == "post.md"));
}
_ => panic!("expected Fill"),
}
let inv = parse_invocation(&["fill".into(), "#q".into(), "--stdin".into()])
.expect("fill --stdin parses");
match inv.action {
Action::Fill { source, .. } => {
assert!(matches!(source, TextInput::Stdin));
}
_ => panic!("expected Fill"),
}
let inv =
parse_invocation(&["type".into(), "#q".into(), "hi".into()]).expect("type parses");
match inv.action {
Action::Type {
selector,
text,
key_events,
..
} => {
assert_eq!(selector, "#q");
assert_eq!(text, "hi");
assert!(!key_events);
}
_ => panic!("expected Type"),
}
let inv = parse_invocation(&[
"type".into(),
"#q".into(),
"hi".into(),
"--key-events".into(),
])
.expect("type --key-events parses");
match inv.action {
Action::Type { key_events, .. } => assert!(key_events),
_ => panic!("expected Type"),
}
let inv = parse_invocation(&[
"press".into(),
"Enter".into(),
"--selector".into(),
"#t".into(),
"--hold".into(),
"50".into(),
"--timeout".into(),
"60".into(),
])
.expect("press parses");
match inv.action {
Action::Press {
key,
selector,
hold,
bound,
} => {
assert_eq!(key, "Enter");
assert_eq!(selector.as_deref(), Some("#t"));
assert_eq!(hold, Some(50));
assert_eq!(bound, Some(Duration::from_secs(60)));
}
_ => panic!("expected Press"),
}
}
#[test]
fn parse_dash_separator_takes_text_verbatim() {
let inv = parse_invocation(&["fill".into(), "#q".into(), "--".into(), "--foo".into()])
.expect("fill -- separator parses");
match inv.action {
Action::Fill { source, .. } => {
assert!(matches!(source, TextInput::Inline(t) if t == "--foo"));
}
_ => panic!("expected Fill"),
}
let inv = parse_invocation(&[
"type".into(),
"#q".into(),
"--".into(),
"--session".into(),
"sneaky".into(),
"rest".into(),
])
.expect("type with --session-looking text parses");
match inv.action {
Action::Type { selector, text, .. } => {
assert_eq!(selector, "#q");
assert_eq!(text, "--session sneaky rest");
}
_ => panic!("expected Type"),
}
}
#[test]
fn fill_type_accept_single_dash_text() {
let inv =
parse_invocation(&["fill".into(), "#q".into(), "-tail".into()]).expect("fill parses");
match inv.action {
Action::Fill { source, .. } => {
assert!(matches!(source, TextInput::Inline(t) if t == "-tail"));
}
_ => panic!("expected Fill"),
}
let inv = parse_invocation(&[
"fill".into(),
"#q".into(),
"-tail".into(),
"--session".into(),
"abc".into(),
])
.expect("fill with trailing --session parses");
assert_eq!(inv.session.as_deref(), Some("abc"));
match inv.action {
Action::Fill { source, .. } => {
assert!(matches!(source, TextInput::Inline(t) if t == "-tail"));
}
_ => panic!("expected Fill"),
}
let inv = parse_invocation(&["type".into(), "#q".into(), "-dashprobe".into()])
.expect("type parses");
match inv.action {
Action::Type { text, .. } => assert_eq!(text, "-dashprobe"),
_ => panic!("expected Type"),
}
assert!(parse_invocation(&["count".into(), ".x".into(), "-bogus".into()]).is_err());
assert!(parse_invocation(&["open".into(), "https://x".into(), "-bogus".into()]).is_err());
assert!(parse_invocation(&["fill".into(), "#q".into(), "--bogus".into()]).is_err());
}
#[test]
fn surface_session_adds_top_level_field() {
let mut env = out_env("fill", true, OutKind::Ok, json!({ "selector": "#q" }));
surface_session(&mut env, "mahbot-chrome-abc");
assert_eq!(env.payload["session"], json!("mahbot-chrome-abc"));
let wire: Value = serde_json::from_str(&env.to_json()).expect("wire json");
assert_eq!(wire["session"], json!("mahbot-chrome-abc"));
assert_eq!(wire["kind"], json!("ok"));
}
#[test]
fn text_value_argv_shields_leading_dash_text() {
assert_eq!(text_value_argv("hello"), vec!["hello"]);
assert_eq!(text_value_argv("--foo"), vec!["--", "--foo"]);
assert_eq!(text_value_argv("-5"), vec!["--", "-5"]);
assert_eq!(text_value_argv(""), vec![""]);
}
#[test]
fn parse_rejects_usage_errors() {
assert!(parse_invocation(&["bogus".into()]).is_err());
assert!(parse_invocation(&["open".into()]).is_err()); assert!(parse_invocation(&["open".into(), "https://x".into(), "--bogus".into()]).is_err());
assert!(parse_invocation(&["count".into()]).is_err()); assert!(parse_invocation(&["extract".into()]).is_err()); assert!(parse_invocation(&["status".into(), "--session".into(), "x".into()]).is_err());
assert!(parse_invocation(&["session".into(), "stop".into()]).is_err()); assert!(
parse_invocation(&[
"open".into(),
"https://x".into(),
"--timeout".into(),
"abc".into()
])
.is_err()
);
assert!(
parse_invocation(&["open".into(), "https://x".into(), "--timeout=0".into()]).is_err()
);
assert!(parse_invocation(&["session".into(), "destroy".into(), "x".into()]).is_err());
assert!(parse_invocation(&["wait".into(), "5000".into()]).is_err());
assert!(
parse_invocation(&[
"wait".into(),
"--url".into(),
"a".into(),
"--text".into(),
"b".into()
])
.is_err()
);
assert!(
parse_invocation(&["wait".into(), "--url".into(), "a".into(), ".x".into()]).is_err()
);
assert!(parse_invocation(&["wait".into(), "#main".into(), "extra".into()]).is_err());
assert!(parse_invocation(&["expect".into()]).is_err());
assert!(parse_invocation(&["expect".into(), "#a".into(), "gone".into()]).is_err());
assert!(
parse_invocation(&[
"expect".into(),
"count".into(),
".c".into(),
"~=".into(),
"2".into()
])
.is_err()
);
assert!(
parse_invocation(&[
"expect".into(),
"count".into(),
".c".into(),
"==".into(),
"x".into()
])
.is_err()
);
assert!(
parse_invocation(&[
"expect".into(),
"text".into(),
"h1".into(),
"starts".into(),
"x".into()
])
.is_err()
);
assert!(
parse_invocation(&["expect".into(), "count".into(), ".c".into(), "==".into()]).is_err()
);
}
#[test]
fn parse_rejects_text_input_usage_errors() {
assert!(parse_invocation(&["fill".into(), "#q".into()]).is_err()); assert!(
parse_invocation(&[
"fill".into(),
"#q".into(),
"a".into(),
"--file".into(),
"f".into()
])
.is_err()
); assert!(
parse_invocation(&["fill".into(), "#q".into(), "a".into(), "--stdin".into()]).is_err()
); assert!(
parse_invocation(&[
"fill".into(),
"#q".into(),
"--stdin".into(),
"--file".into(),
"f".into()
])
.is_err()
); assert!(parse_invocation(&["type".into(), "#q".into()]).is_err()); assert!(parse_invocation(&["press".into(), "Enter".into(), "extra".into()]).is_err()); assert!(
parse_invocation(&[
"press".into(),
"Enter".into(),
"--hold".into(),
"abc".into()
])
.is_err()
); assert!(parse_invocation(&["press".into(), "Enter".into(), "--selector".into()]).is_err()); }
#[test]
fn text_input_ok_envelope_clean_success() {
let base = json!({ "selector": "#q" });
let resp = ChromeResponse {
success: Some(true),
data: Some(json!({ "value": "filled" })),
..ChromeResponse::default()
};
let env = text_input_ok_envelope("fill", &base, resp);
assert!(env.ok);
assert_eq!(env.kind, OutKind::Ok);
assert_eq!(env.payload["data"]["value"], json!("filled"));
assert!(env.payload.get("warning").is_none());
}
#[test]
fn text_input_ok_envelope_read_back_and_key_listeners_without_warning_are_ok() {
let typed = ChromeResponse {
success: Some(true),
data: Some(json!({ "typed": "hi", "readBack": "hi" })),
..ChromeResponse::default()
};
let env = text_input_ok_envelope("type", &json!({ "selector": "#q", "text": "hi" }), typed);
assert!(env.ok);
assert_eq!(env.kind, OutKind::Ok);
let press = ChromeResponse {
success: Some(true),
data: Some(json!({ "key": "ArrowDown", "keyListeners": 3 })),
..ChromeResponse::default()
};
let env = text_input_ok_envelope("press", &json!({ "key": "ArrowDown" }), press);
assert!(env.ok);
assert_eq!(env.kind, OutKind::Ok);
}
#[test]
fn text_input_ok_envelope_warning_in_data_is_error() {
let base = json!({ "selector": "#q", "text": "hi" });
let resp = ChromeResponse {
success: Some(true),
data: Some(json!({
"typed": "hi", "readBack": "h",
"warning": "the field does not contain what was typed",
})),
..ChromeResponse::default()
};
let env = text_input_ok_envelope("type", &base, resp);
assert!(!env.ok);
assert_eq!(env.kind, OutKind::Error);
assert_eq!(
env.payload["warning"],
json!("the field does not contain what was typed")
);
}
#[test]
fn text_input_ok_envelope_warning_at_top_level_is_error() {
let base = json!({ "key": "Enter" });
let resp = ChromeResponse {
success: Some(true),
data: Some(json!({})),
extra: serde_json::Map::from_iter([("warning".to_string(), json!("no key listeners"))]),
..ChromeResponse::default()
};
let env = text_input_ok_envelope("press", &base, resp);
assert!(!env.ok);
assert_eq!(env.kind, OutKind::Error);
assert_eq!(env.payload["warning"], json!("no key listeners"));
}
#[test]
fn recognized_action_falls_back_to_usage() {
assert_eq!(
recognized_action(&["open".into(), "https://x".into()]),
"open"
);
assert_eq!(recognized_action(&["bogus".into()]), "usage");
assert_eq!(
recognized_action(&["--session".into(), "s".into(), "count".into(), ".x".into()]),
"count"
);
assert_eq!(
recognized_action(&["--session=s".into(), "count".into(), ".x".into()]),
"count"
);
}
#[test]
fn top_help_lists_every_cli_action_with_purpose() {
let help = top_help();
for d in actions::ACTIONS.iter().filter(|a| a.cli.is_some()) {
assert!(help.contains(d.name), "top help missing action {}", d.name);
assert!(
help.contains(d.purpose),
"top help missing purpose for {}",
d.name
);
}
}
#[test]
fn action_help_covers_flags_kinds_and_examples() {
for d in actions::ACTIONS.iter().filter(|a| a.cli.is_some()) {
let cli = d.cli.as_ref().expect("filtered to cli actions");
let help = action_help(d.name);
assert!(help.contains("Usage:"), "{}: missing Usage", d.name);
assert!(help.contains("Kinds"), "{}: missing Kinds", d.name);
assert!(help.contains("Examples:"), "{}: missing Examples", d.name);
for k in cli.kinds {
assert!(
help.contains(&format!("{} ({})", k.as_str(), k.exit_code())),
"{}: missing kind {}",
d.name,
k.as_str()
);
}
}
assert!(action_help("open").contains("--expect"));
assert!(action_help("open").contains("redesign"));
assert!(!action_help("status").contains("Flags:"));
}
#[test]
fn action_flags_match_the_cli_help_flag_sets() {
let mut table: Vec<&str> = ACTION_FLAGS.iter().map(|(name, ..)| *name).collect();
table.sort_unstable();
let mut cli: Vec<&str> = actions::ACTIONS
.iter()
.filter(|a| a.cli.is_some())
.map(|a| a.name)
.collect();
cli.sort_unstable();
assert_eq!(table, cli, "ACTION_FLAGS must cover the CLI action words");
for &(word, value_flags, bool_flags) in ACTION_FLAGS {
let help = actions::desc(word)
.and_then(|d| d.cli.as_ref())
.expect("ACTION_FLAGS word should have a CliHelp");
let mut expected: Vec<&str> = help
.flags
.iter()
.map(|(flag, _)| {
flag.strip_prefix("--")
.unwrap_or(flag)
.split_whitespace()
.next()
.unwrap_or(flag)
})
.collect();
let mut actual: Vec<&str> = value_flags
.iter()
.chain(bool_flags.iter())
.copied()
.collect();
if help.session {
expected.push("session");
actual.push("session");
}
expected.sort_unstable();
actual.sort_unstable();
assert_eq!(
actual, expected,
"flag set mismatch for action {word}: ACTION_FLAGS vs CliHelp"
);
}
}
#[test]
fn action_help_request_intercepts_per_action_help() {
let arg = |s: &[&str]| s.iter().copied().map(String::from).collect::<Vec<_>>();
assert_eq!(action_help_request(&arg(&["open", "-h"])), Some("open"));
assert_eq!(
action_help_request(&arg(&["open", "https://x", "--help"])),
Some("open")
);
assert_eq!(
action_help_request(&arg(&["--session", "docs", "open", "-h"])),
Some("open")
);
assert_eq!(action_help_request(&arg(&["open"])), None);
assert_eq!(action_help_request(&arg(&["snapshot", "-h"])), None);
assert_eq!(action_help_request(&arg(&["frobnicate", "-h"])), None);
assert_eq!(action_help_request(&arg(&["--timeout", "5"])), None);
}
#[test]
fn out_env_kind_contract_is_enforced() {
for d in actions::ACTIONS.iter().filter(|a| a.cli.is_some()) {
let cli = d.cli.as_ref().expect("filtered above");
for k in cli.kinds {
assert!(
kind_contract_holds(d.name, *k),
"{}: declared kind {} fails the contract",
d.name,
k.as_str()
);
}
}
assert!(!kind_contract_holds("status", OutKind::Network));
assert!(kind_contract_holds("usage", OutKind::Usage));
}
}