use regex::Regex;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::sync::LazyLock;
#[derive(Debug, Default, Deserialize)]
pub(crate) struct ChromeResponse {
#[serde(default)]
pub(crate) success: Option<bool>,
#[serde(default)]
pub(crate) ok: Option<bool>,
pub(crate) data: Option<Value>,
pub(crate) error: Option<String>,
pub(crate) code: Option<String>,
#[serde(default)]
pub(crate) retryable: Option<bool>,
#[serde(flatten)]
pub(crate) extra: serde_json::Map<String, Value>,
}
impl ChromeResponse {
pub(crate) fn is_success(&self) -> bool {
envelope_success(self.success, self.ok)
}
pub(crate) fn verdict(&self) -> Option<bool> {
if self.success.is_none() && self.ok.is_none() {
return None;
}
Some(self.is_success())
}
#[must_use]
pub(crate) fn from_value(v: &Value) -> Self {
serde_json::from_value(v.clone()).unwrap_or_default()
}
}
pub(crate) fn chrome_use_warning(resp: &ChromeResponse) -> Option<Value> {
let sources: [Option<&serde_json::Map<String, Value>>; 2] = [
Some(&resp.extra),
resp.data.as_ref().and_then(Value::as_object),
];
sources
.into_iter()
.flatten()
.find_map(|map| map.get("warning").cloned())
}
const SELF_LAUNCHED_BROWSER_PHRASES: [&str; 2] =
["previous browser is gone", "a fresh one was launched"];
#[must_use]
pub(crate) fn self_launched_browser_note(resp: &ChromeResponse) -> Option<String> {
let warning = chrome_use_warning(resp)?;
let text = warning
.as_str()
.map_or_else(|| warning.to_string(), str::to_string);
let lower = text.to_ascii_lowercase();
SELF_LAUNCHED_BROWSER_PHRASES
.iter()
.any(|phrase| lower.contains(phrase))
.then_some(text)
}
#[must_use]
pub(crate) fn self_launched_browser_error(note: &str) -> String {
format!(
"chrome-use launched a browser of its own for this command ({note}). The work ended up in \
a browser chrome-use started itself, not the owner's real logged-in Chrome — the tool \
could not resolve the connection to the real browser, so the page state this action \
assumed is not there and nothing read or written here happened in the owner's session. \
Retry the action; if it keeps happening the relay or the chrome-use extension is the \
problem rather than the page — the chrome daemon itself is healthy, so check the \
extension at chrome://extensions and the relay, then retry."
)
}
#[must_use]
pub(crate) fn truncated_output_error() -> String {
format!(
"the answer chrome-use wrote was cut off by mahbot before it could be read in full — it \
exceeded the {} MiB mahbot collects per stream, or a process the command left behind kept \
writing past the collection window — so it could not be parsed. The command itself may \
well have succeeded: retry with a narrower action (a smaller selector or page region) \
rather than reading this as malformed output from chrome-use.",
crate::chrome::spawn::PIPE_READ_CAP / (1024 * 1024)
)
}
pub(crate) fn parse_first<T: serde::de::DeserializeOwned>(bytes: &[u8]) -> Option<T> {
serde_json::Deserializer::from_slice(bytes)
.into_iter::<T>()
.next()?
.ok()
}
#[must_use]
pub(crate) fn chrome_leftover_pipe_note(session: &str) -> String {
format!(
"[leftover helper] a helper chrome-use starts itself — the daemon of chrome-use session \
`{session}` — still holds the call's output channel, so no more of its output could be \
collected. chrome-use starts that helper on its own and stops it on its own idle timeout; \
to stop it now, run `{}`.",
chrome_session_stop_command(session)
)
}
#[must_use]
pub(crate) fn chrome_session_stop_command(session: &str) -> String {
format!("mahbot chrome session stop {session} --force")
}
fn envelope_success(success: Option<bool>, ok: Option<bool>) -> bool {
!(success == Some(false) || ok == Some(false)) && (success == Some(true) || ok == Some(true))
}
pub(crate) fn extract_snapshot_text(data: &serde_json::Value) -> Option<String> {
data.as_str().map(String::from).or_else(|| {
["text", "result", "content", "snapshot"]
.iter()
.find_map(|key| data.get(*key).and_then(Value::as_str).map(String::from))
})
}
pub(crate) fn eval_result(resp: &ChromeResponse) -> Option<&Value> {
resp.data
.as_ref()
.map(|d| match (d.get("result"), d.get("origin")) {
(Some(result), Some(_)) => result,
_ => d,
})
}
pub(crate) const ERROR_PAGE_PROBE_JS: &str = "JSON.stringify({err: location.protocol === 'chrome-error:', code: (document.querySelector('.error-code') || {}).textContent || ''})";
#[derive(Debug, PartialEq)]
pub(crate) struct ErrorPageProbe {
pub(crate) is_error_page: bool,
pub(crate) code: Option<String>,
}
pub(crate) fn parse_error_page_probe(resp: &ChromeResponse) -> Option<ErrorPageProbe> {
let data = eval_result(resp)?;
let verdict = if let Some(text) = extract_snapshot_text(data) {
let t = text.trim();
if t.is_empty() || t.eq_ignore_ascii_case("null") || t.eq_ignore_ascii_case("undefined") {
return None;
}
serde_json::from_str::<Value>(t)
.ok()
.or_else(|| extract_net_error_code(t).map(|c| json!({ "err": true, "code": c })))
} else if let v @ Value::Object(_) = data {
Some(v.clone())
} else {
None
}?;
let is_error_page = verdict.get("err").and_then(Value::as_bool) == Some(true);
let code = is_error_page.then(|| {
verdict
.get("code")
.and_then(Value::as_str)
.unwrap_or_default()
});
Some(ErrorPageProbe {
is_error_page,
code: code.and_then(extract_net_error_code),
})
}
pub(crate) fn eval_count(resp: &ChromeResponse) -> Option<u64> {
let data = eval_result(resp)?;
data.as_u64()
.or_else(|| data.as_str().and_then(|s| s.trim().parse::<u64>().ok()))
}
fn trim_extract_rows(rows: Vec<Value>, limit: Option<usize>) -> (Vec<Value>, usize, usize) {
let total = rows.len();
let returned = limit.map_or(total, |l| l.min(total));
let trimmed: Vec<Value> = rows.into_iter().take(returned).collect();
(trimmed, total, returned)
}
fn extract_rows(data: &Value) -> Vec<Value> {
match data {
Value::Object(o) => match o.get("extracted") {
Some(Value::Array(a)) => a.clone(),
Some(other) => vec![other.clone()],
None => vec![Value::Object(o.clone())],
},
Value::Array(a) => a.clone(),
Value::Null => Vec::new(),
other => vec![other.clone()],
}
}
#[must_use]
pub(crate) fn extract_output(data: &Value, limit: Option<usize>) -> Value {
let (trimmed, total, returned) = trim_extract_rows(extract_rows(data), limit);
let mut payload = json!({ "data": trimmed, "total": total });
if limit.is_some() {
payload["returned"] = json!(returned);
}
payload
}
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct ExpectOutcome {
pub(crate) pass: bool,
pub(crate) actual: Option<Value>,
pub(crate) timed_out: bool,
}
#[must_use]
pub(crate) fn expect_outcome(data: &Value) -> Option<ExpectOutcome> {
let obj = data.as_object()?;
let pass = obj.get("pass")?.as_bool()?;
Some(ExpectOutcome {
pass,
actual: obj.get("actual").cloned(),
timed_out: obj
.get("timedOut")
.and_then(Value::as_bool)
.unwrap_or(false),
})
}
pub(crate) const SCHEMA_VERSION: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum OutKind {
Ok,
Empty,
Timeout,
Network,
Redesign,
NotFound,
Error,
Environment,
Usage,
}
impl OutKind {
#[must_use]
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Ok => "ok",
Self::Empty => "empty",
Self::Timeout => "timeout",
Self::Network => "network",
Self::Redesign => "redesign",
Self::NotFound => "not-found",
Self::Error => "error",
Self::Environment => "environment",
Self::Usage => "usage",
}
}
#[must_use]
pub(crate) const fn exit_code(self) -> i32 {
match self {
Self::Ok | Self::Empty => 0,
Self::Timeout | Self::Network | Self::Redesign | Self::NotFound | Self::Error => 1,
Self::Environment => 2,
Self::Usage => 3,
}
}
}
pub(crate) fn is_session_unresponsive_error(msg: &str) -> bool {
let lower = msg.to_ascii_lowercase();
lower.contains("session unresponsive")
|| lower.contains("cdp session is unresponsive after attaching")
}
pub(crate) fn is_daemon_unavailable_error(msg: &str) -> bool {
if is_session_unresponsive_error(msg) {
return true;
}
let lower = msg.to_ascii_lowercase();
lower.contains("resource temporarily unavailable")
|| lower.contains("os error 35")
|| lower.contains("os error 11")
|| lower.contains("daemon may be busy or unresponsive")
|| lower.contains("daemon failed to start")
|| lower.contains("auto-launch failed")
|| lower.contains("failed to connect:")
}
pub(crate) fn is_relay_unavailable_error(msg: &str) -> bool {
let lower = msg.to_ascii_lowercase();
lower.contains("relay isn't connected")
|| lower.contains("relay is not")
|| lower.contains("relay dropped")
|| lower.contains("relay down")
|| lower.contains("could not drive your chrome")
}
pub(crate) fn is_daemon_unavailable_code(code: Option<&str>) -> bool {
matches!(code, Some("browser_not_launched"))
}
pub(crate) fn is_unreachable_tab_error(msg: &str) -> bool {
let lower = msg.to_ascii_lowercase();
lower.contains("can no longer be resolved")
|| lower.contains("owns no resolvable tab")
|| lower.contains("no attached tab")
|| lower.contains("its tab is gone")
|| lower.contains("stale session")
|| lower.contains("unknown session")
|| lower.contains("the relay lost it")
}
pub(crate) fn unreachable_tab_message(error: &str) -> String {
format!(
"{error}. The chrome-use extension lost its debugger attach to this tab and never \
re-attaches about:blank tabs — close the leftover tab in Chrome to unblock this \
session (the chrome daemon itself is healthy)."
)
}
pub(crate) fn classify_call_failure(code: Option<&str>, error: &str) -> OutKind {
if is_daemon_unavailable_error(error)
|| is_daemon_unavailable_code(code)
|| is_relay_unavailable_error(error)
{
return OutKind::Environment;
}
if error.contains("net::ERR_") || code == Some("connection_failed") {
return OutKind::Network;
}
let lower = error.to_ascii_lowercase();
if code == Some("timeout")
|| lower.contains("timed out after")
|| lower.contains("waitfor timed out")
{
return OutKind::Timeout;
}
if code == Some("element_not_found") || lower.contains("element not found") {
return OutKind::NotFound;
}
OutKind::Error
}
pub(crate) fn extract_net_error_code(text: &str) -> Option<String> {
static NET_ERROR_CODE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?:net::)?\b(ERR_[A-Z0-9_]+|DNS_[A-Z0-9_]+)").expect("valid net error regex")
});
NET_ERROR_CODE
.captures(text)
.and_then(|c| c.get(1))
.map(|m| m.as_str().to_string())
}
pub(crate) fn net_error_phrase(code: &str) -> &'static str {
if code.starts_with("DNS_") || code == "ERR_NAME_NOT_RESOLVED" {
"DNS resolution failure"
} else if code == "ERR_CONNECTION_REFUSED" {
"connection refused"
} else {
"site unreachable or connection failed"
}
}
const STALE_RELAY_HINT_MARKER: &str = "Hint: the session's browser connection is unresponsive";
pub(crate) fn sanitize_timeout_message(kind: OutKind, message: &str) -> String {
if kind != OutKind::Timeout {
return message.to_string();
}
match message.find(STALE_RELAY_HINT_MARKER) {
Some(idx) => {
let head = message[..idx].trim_end();
head.strip_suffix('.').unwrap_or(head).to_string()
}
None => message.to_string(),
}
}
pub(crate) const WAIT_TIMEOUT_NOTE: &str = " — the target never appeared within the deadline; consider verifying the \
selector/text/URL or allowing more time";
pub(crate) const EXPECT_TIMEOUT_NOTE: &str =
" — consider verifying the condition or allowing more time";
pub(crate) fn with_condition_timeout_note(action: &str, kind: OutKind, message: &mut String) {
let note = match (kind, action) {
(OutKind::Timeout, "wait") => Some(WAIT_TIMEOUT_NOTE),
(OutKind::Timeout, "expect") => Some(EXPECT_TIMEOUT_NOTE),
_ => None,
};
if let Some(note) = note
&& !message.is_empty()
{
message.push_str(note);
}
}
pub(crate) struct OutEnvelope {
pub(crate) action: String,
pub(crate) ok: bool,
pub(crate) kind: OutKind,
pub(crate) payload: serde_json::Value,
}
impl OutEnvelope {
#[must_use]
pub(crate) fn to_json(&self) -> String {
#[derive(Serialize)]
struct Wire<'a> {
schema: u32,
action: &'a str,
ok: bool,
kind: &'a str,
#[serde(flatten)]
payload: &'a serde_json::Value,
}
let wire = Wire {
schema: SCHEMA_VERSION,
action: &self.action,
ok: self.ok,
kind: self.kind.as_str(),
payload: &self.payload,
};
serde_json::to_string(&wire).expect("OutEnvelope serialization cannot fail")
}
pub(crate) fn emit(&self) {
println!("{}", self.to_json());
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn chrome_response_accepts_tolerant_envelopes() {
let ok: ChromeResponse =
serde_json::from_str(r#"{"ok":true,"data":{}}"#).expect("tolerant deserialize");
assert!(ok.is_success());
let success: ChromeResponse =
serde_json::from_str(r#"{"success":true}"#).expect("tolerant deserialize");
assert!(success.is_success());
let failed: ChromeResponse =
serde_json::from_str(r#"{"success":false}"#).expect("tolerant deserialize");
assert!(!failed.is_success());
let mixed: ChromeResponse =
serde_json::from_str(r#"{"success":false,"ok":true}"#).expect("tolerant deserialize");
assert!(!mixed.is_success());
}
#[test]
fn parse_first_ignores_anything_after_the_first_value() {
let envelope = br#"{"ok":true,"data":{"count":1}}"#;
assert!(parse_first::<ChromeResponse>(envelope).is_some_and(|r| r.is_success()));
let mut trailing = envelope.to_vec();
trailing.extend_from_slice(b"\nsomeone the command left behind is still writing\n");
let parsed = parse_first::<ChromeResponse>(&trailing).expect("trailing bytes ignored");
assert_eq!(parsed.data.expect("data present")["count"], 1);
assert!(parse_first::<Value>(&trailing).is_some());
assert!(parse_first::<ChromeResponse>(br#"{"ok":true,"data":{"co"#).is_none());
assert!(parse_first::<ChromeResponse>(b"chrome-use is not installed\n").is_none());
}
#[test]
fn verdict_covers_both_envelopes() {
let v = |json: serde_json::Value| ChromeResponse::from_value(&json).verdict();
assert_eq!(v(serde_json::json!({"success": true})), Some(true));
assert_eq!(v(serde_json::json!({"ok": true})), Some(true));
assert_eq!(
v(serde_json::json!({"success": false, "error": "x"})),
Some(false)
);
assert_eq!(
v(serde_json::json!({"ok": false, "error": "x"})),
Some(false)
);
assert_eq!(
v(serde_json::json!({"success": false, "ok": true})),
Some(false)
);
assert_eq!(v(serde_json::json!({"data": {}})), None);
}
#[test]
fn self_launched_browser_note_matches_the_replacement_warning_only() {
let note =
|v: serde_json::Value| self_launched_browser_note(&ChromeResponse::from_value(&v));
assert!(
note(serde_json::json!({
"success": true,
"warning": "This session's previous browser is gone (its browser connection was dead) and a fresh one was launched for this command"
}))
.is_some()
);
assert!(
note(serde_json::json!({
"ok": true,
"data": { "warning": "This session's previous browser is gone (its browser process had exited) and a fresh one was launched for this command" }
}))
.is_some()
);
assert!(
note(serde_json::json!({
"success": true,
"warning": "typed text was rewritten by the page: expected 'ab', read back 'a'"
}))
.is_none()
);
assert!(
note(serde_json::json!({
"success": true,
"data": { "warning": "no key listeners on the focused element" }
}))
.is_none()
);
assert!(note(serde_json::json!({ "success": true, "data": {} })).is_none());
}
#[test]
fn leftover_note_names_the_session_the_stop_and_the_idle_timeout() {
let note = chrome_leftover_pipe_note("mahbot-chrome-agent-tab-abc-default");
assert!(
note.contains("mahbot-chrome-agent-tab-abc-default"),
"{note}"
);
assert!(
note.contains("mahbot chrome session stop mahbot-chrome-agent-tab-abc-default --force"),
"{note}"
);
assert!(note.contains("idle timeout"), "{note}");
assert!(
note.contains("no more of its output could be collected"),
"{note}"
);
assert!(!note.contains("result"), "{note}");
assert!(!note.contains("failed"), "{note}");
}
#[test]
fn chrome_response_parses_structured_error_retryable() {
let v = serde_json::json!({
"success": false, "error": "boom", "code": "connection_failed", "retryable": true,
});
let resp = ChromeResponse::from_value(&v);
assert_eq!(resp.success, Some(false));
assert_eq!(resp.error.as_deref(), Some("boom"));
assert_eq!(resp.code.as_deref(), Some("connection_failed"));
assert_eq!(resp.retryable, Some(true));
assert!(!resp.is_success());
let v = serde_json::json!({
"ok": false, "code": "connection_failed", "retryable": false,
});
let resp = ChromeResponse::from_value(&v);
assert_eq!(resp.ok, Some(false));
assert_eq!(resp.retryable, Some(false));
assert!(!resp.is_success());
}
#[test]
fn from_value_tolerates_unknown_keys_and_non_objects() {
let resp = ChromeResponse::from_value(&serde_json::json!({
"success": true, "version": 99, "unknown": [1, 2, 3],
}));
assert_eq!(resp.success, Some(true));
assert!(resp.is_success());
let resp = ChromeResponse::from_value(&serde_json::json!({
"data": {"x": 1}, "future_key": true,
}));
assert_eq!(resp.verdict(), None);
assert_eq!(resp.data, Some(serde_json::json!({"x": 1})));
assert_eq!(
ChromeResponse::from_value(&serde_json::json!("plain string")).verdict(),
None
);
}
#[test]
fn extract_snapshot_text_handles_verified_shapes() {
let text = |v: serde_json::Value| extract_snapshot_text(&v);
assert_eq!(
text(serde_json::json!("plain text")),
Some("plain text".into())
);
assert_eq!(
text(serde_json::json!({"text": "hello"})),
Some("hello".into())
);
assert_eq!(text(serde_json::json!({"result": "42"})), Some("42".into()));
assert_eq!(
text(serde_json::json!({"content": "page"})),
Some("page".into())
);
assert_eq!(
text(serde_json::json!({"snapshot": "snap"})),
Some("snap".into())
);
assert_eq!(text(serde_json::json!({"result": 42})), None);
assert_eq!(text(serde_json::json!({})), None);
}
#[test]
fn out_kind_exit_code_table() {
assert_eq!(OutKind::Ok.exit_code(), 0);
assert_eq!(OutKind::Empty.exit_code(), 0);
assert_eq!(OutKind::Timeout.exit_code(), 1);
assert_eq!(OutKind::Network.exit_code(), 1);
assert_eq!(OutKind::Redesign.exit_code(), 1);
assert_eq!(OutKind::NotFound.exit_code(), 1);
assert_eq!(OutKind::Error.exit_code(), 1);
assert_eq!(OutKind::Environment.exit_code(), 2);
assert_eq!(OutKind::Usage.exit_code(), 3);
}
#[test]
fn out_kind_as_str_matches_contract() {
for (kind, s) in [
(OutKind::Ok, "ok"),
(OutKind::Empty, "empty"),
(OutKind::Timeout, "timeout"),
(OutKind::Network, "network"),
(OutKind::Redesign, "redesign"),
(OutKind::NotFound, "not-found"),
(OutKind::Error, "error"),
(OutKind::Environment, "environment"),
(OutKind::Usage, "usage"),
] {
assert_eq!(kind.as_str(), s);
}
}
#[test]
fn out_envelope_one_line_json_flattens_payload() {
let env = OutEnvelope {
action: "status".into(),
ok: true,
kind: OutKind::Ok,
payload: serde_json::json!({ "chrome_use": "present", "relay_up": true }),
};
assert_eq!(
env.to_json(),
r#"{"schema":1,"action":"status","ok":true,"kind":"ok","chrome_use":"present","relay_up":true}"#
);
}
#[test]
fn extract_output_reads_the_chrome_use_15101_envelope() {
let data = serde_json::json!({
"extracted": [{"a": 1}, {"a": 2}, {"a": 3}],
"count": 3,
"origin": "https://x",
"meta": {},
});
let out = extract_output(&data, Some(2));
assert_eq!(
out["data"],
serde_json::json!([{"a": 1}, {"a": 2}]),
"limit trims rows"
);
assert_eq!(out["total"], 3, "total stays honest");
assert_eq!(out["returned"], 2);
let out = extract_output(&data, None);
assert_eq!(out["total"], 3);
assert!(out.get("returned").is_none());
}
#[test]
fn extract_output_tolerates_other_shapes() {
let arr = serde_json::json!([{"a": 1}]);
assert_eq!(
extract_output(&arr, None)["data"],
serde_json::json!([{"a": 1}])
);
let obj = serde_json::json!({"a": 1});
assert_eq!(
extract_output(&obj, None)["data"],
serde_json::json!([{"a": 1}])
);
let weird = serde_json::json!({"extracted": "weird"});
assert_eq!(
extract_output(&weird, None)["data"],
serde_json::json!(["weird"])
);
let out = extract_output(&serde_json::Value::Null, None);
assert_eq!(out["data"], serde_json::json!([]));
assert_eq!(out["total"], 0);
}
#[test]
fn expect_outcome_parses_pass_fail_and_timeout() {
let o = expect_outcome(&serde_json::json!({"pass": true})).expect("verdict present");
assert!(o.pass);
assert_eq!(o.actual, None);
assert!(!o.timed_out);
let o = expect_outcome(&serde_json::json!({
"pass": false, "actual": 42, "timedOut": true, "condition": "x", "kind": "count",
}))
.expect("verdict present");
assert!(!o.pass);
assert_eq!(o.actual, Some(serde_json::json!(42)));
assert!(o.timed_out);
assert_eq!(expect_outcome(&serde_json::json!({"success": true})), None);
assert_eq!(expect_outcome(&serde_json::json!({"actual": 1})), None);
assert_eq!(expect_outcome(&serde_json::json!("x")), None);
}
#[test]
fn classify_call_failure_mapping() {
assert_eq!(
classify_call_failure(None, "daemon may be busy or unresponsive"),
OutKind::Environment
);
assert_eq!(
classify_call_failure(Some("browser_not_launched"), "whatever"),
OutKind::Environment
);
assert_eq!(
classify_call_failure(None, "relay isn't connected"),
OutKind::Environment
);
assert_eq!(
classify_call_failure(None, "net::ERR_NAME_NOT_RESOLVED"),
OutKind::Network
);
assert_eq!(
classify_call_failure(Some("connection_failed"), "connection refused"),
OutKind::Network
);
assert_eq!(
classify_call_failure(None, "Wait timed out after 15000ms"),
OutKind::Timeout
);
assert_eq!(
classify_call_failure(None, "waitFor timed out: .submit"),
OutKind::Timeout
);
assert_eq!(
classify_call_failure(Some("element_not_found"), "whatever"),
OutKind::NotFound
);
assert_eq!(
classify_call_failure(None, "Element not found"),
OutKind::NotFound
);
assert_eq!(
classify_call_failure(None, "some random issue"),
OutKind::Error
);
assert_eq!(
classify_call_failure(Some("other_code"), "some issue"),
OutKind::Error
);
}
#[test]
fn net_error_code_extraction_canonicalizes_both_forms() {
assert_eq!(
extract_net_error_code("Navigation failed: net::ERR_NAME_NOT_RESOLVED"),
Some("ERR_NAME_NOT_RESOLVED".into())
);
assert_eq!(
extract_net_error_code("ERR_CONNECTION_REFUSED"),
Some("ERR_CONNECTION_REFUSED".into())
);
assert_eq!(
extract_net_error_code("DNS_PROBE_FINISHED_NXDOMAIN"),
Some("DNS_PROBE_FINISHED_NXDOMAIN".into())
);
assert_eq!(
extract_net_error_code("error: net::ERR_CONNECTION_TIMED_OUT — retry"),
Some("ERR_CONNECTION_TIMED_OUT".into())
);
assert_eq!(extract_net_error_code("connection refused by peer"), None);
assert_eq!(extract_net_error_code(""), None);
}
#[test]
fn net_error_phrases_bucket_dns_refused_other() {
assert_eq!(
net_error_phrase("DNS_PROBE_FINISHED_NXDOMAIN"),
"DNS resolution failure"
);
assert_eq!(
net_error_phrase("ERR_NAME_NOT_RESOLVED"),
"DNS resolution failure"
);
assert_eq!(
net_error_phrase("ERR_CONNECTION_REFUSED"),
"connection refused"
);
assert_eq!(
net_error_phrase("ERR_CONNECTION_RESET"),
"site unreachable or connection failed"
);
}
#[test]
fn error_page_probe_parses_verdict_shapes() {
let resp = |data: Value| ChromeResponse {
data: Some(data),
..Default::default()
};
let clean = resp(json!({ "origin": "x", "result": r#"{"err": false, "code": ""}"# }));
assert_eq!(
parse_error_page_probe(&clean),
Some(ErrorPageProbe {
is_error_page: false,
code: None
})
);
let failed = resp(
json!({ "origin": "x", "result": r#"{"err": true, "code": "ERR_CONNECTION_REFUSED"}"# }),
);
assert_eq!(
parse_error_page_probe(&failed),
Some(ErrorPageProbe {
is_error_page: true,
code: Some("ERR_CONNECTION_REFUSED".into())
})
);
let no_code = resp(json!({ "origin": "x", "result": r#"{"err": true, "code": ""}"# }));
assert_eq!(
parse_error_page_probe(&no_code),
Some(ErrorPageProbe {
is_error_page: true,
code: None
})
);
let bare = resp(json!(
r#"{"err": true, "code": "DNS_PROBE_FINISHED_NXDOMAIN"}"#
));
assert_eq!(
parse_error_page_probe(&bare),
Some(ErrorPageProbe {
is_error_page: true,
code: Some("DNS_PROBE_FINISHED_NXDOMAIN".into())
})
);
let object = resp(json!({ "err": true, "code": "ERR_UNSAFE_PORT" }));
assert_eq!(
parse_error_page_probe(&object),
Some(ErrorPageProbe {
is_error_page: true,
code: Some("ERR_UNSAFE_PORT".into())
})
);
let token_text = resp(json!("net::ERR_INTERNET_DISCONNECTED"));
assert_eq!(
parse_error_page_probe(&token_text),
Some(ErrorPageProbe {
is_error_page: true,
code: Some("ERR_INTERNET_DISCONNECTED".into())
})
);
assert_eq!(parse_error_page_probe(&resp(json!(null))), None);
assert_eq!(parse_error_page_probe(&resp(json!(""))), None);
assert_eq!(parse_error_page_probe(&resp(json!("garbage text"))), None);
assert_eq!(parse_error_page_probe(&resp(json!(true))), None);
}
#[test]
fn sanitize_timeout_message_strips_stale_relay_hint() {
assert_eq!(
sanitize_timeout_message(
OutKind::Timeout,
"Wait timed out after 15000ms. 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."
),
"Wait timed out after 15000ms"
);
assert_eq!(
sanitize_timeout_message(OutKind::Timeout, "Wait timed out after 19000ms"),
"Wait timed out after 19000ms"
);
}
#[test]
fn sanitize_timeout_message_leaves_non_timeout_alone() {
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.";
assert_eq!(sanitize_timeout_message(OutKind::Error, hint), hint);
let relay = "relay isn't connected";
assert_eq!(sanitize_timeout_message(OutKind::Environment, relay), relay);
let other = format!("some other failure\n{hint}");
assert_eq!(sanitize_timeout_message(OutKind::Error, &other), other);
}
#[test]
fn with_condition_timeout_note_appends_by_action() {
let mut m = String::from("Wait timed out after 15000ms");
with_condition_timeout_note("wait", OutKind::Timeout, &mut m);
assert_eq!(
m,
format!("Wait timed out after 15000ms{WAIT_TIMEOUT_NOTE}")
);
let mut m = String::from("waitFor timed out: .submit");
with_condition_timeout_note("expect", OutKind::Timeout, &mut m);
assert_eq!(
m,
format!("waitFor timed out: .submit{EXPECT_TIMEOUT_NOTE}")
);
let mut m = String::new();
with_condition_timeout_note("wait", OutKind::Timeout, &mut m);
assert!(m.is_empty());
let mut m = String::from("some failure");
with_condition_timeout_note("wait", OutKind::Error, &mut m);
with_condition_timeout_note("eval", OutKind::Timeout, &mut m);
assert_eq!(m, "some failure");
}
#[test]
fn session_unresponsive_error_signature_detected() {
for msg in [
"session unresponsive: no response within 45s",
"CDP session is unresponsive after attaching (Connection reset).",
] {
assert!(is_session_unresponsive_error(msg), "should detect: {msg}");
}
assert!(!is_session_unresponsive_error(
"daemon may be busy or unresponsive"
));
}
#[test]
fn unreachable_tab_signature_and_message_are_stable() {
for msg in [
"the tab this session was driving can no longer be resolved (it was closed, or a flaky relay dropped it)",
"the tab this command was driving is gone — it may have been closed, or the relay lost it",
"this session owns no resolvable tab in its group. Refusing to run on a tab this session does not drive",
"no attached tab ...",
"unknown sessionId ...",
"stale sessionId ... its tab is gone",
] {
assert!(is_unreachable_tab_error(msg), "should detect: {msg}");
}
assert!(!is_unreachable_tab_error(
"the tab this command was driving is gone — it navigated across processes"
));
assert!(!is_unreachable_tab_error(
"Auto-launch failed: Could not drive your Chrome through the ab-connect extension."
));
assert!(!is_unreachable_tab_error(
"Failed to read: Resource temporarily unavailable (os error 35)"
));
assert!(!is_unreachable_tab_error(
"chrome-use error: Element not found"
));
let m = unreachable_tab_message("this session owns no resolvable tab");
assert!(m.ends_with("the chrome daemon itself is healthy)."));
assert!(m.contains("close the leftover tab in Chrome"));
}
}