fn invocation(browser: &str) -> String {
if browser == "default" {
"chrome-agent".to_string()
} else {
format!("chrome-agent --browser {browser}")
}
}
fn uid_in(msg: &str) -> Option<&str> {
let rest = msg.split("uid=").nth(1)?;
let end = rest.find(|c: char| !c.is_ascii_alphanumeric()).unwrap_or(rest.len());
(end > 0).then(|| &rest[..end])
}
#[must_use]
pub fn usage_error(rendered: &str, argv: &[String]) -> String {
let Some((flag, why)) = crate::cli::BEFORE_VERB_ONLY
.iter()
.copied()
.find(|(flag, _)| rendered.contains(&format!("unexpected argument '{flag}'")))
else {
return rendered.to_string();
};
let mut out = String::new();
let mut skip_blank = false;
for line in rendered.lines() {
if line.trim_start().starts_with(&format!("tip: to pass '{flag}'")) {
skip_blank = true;
continue;
}
if skip_blank && line.trim().is_empty() {
skip_blank = false;
continue;
}
skip_blank = false;
out.push_str(line);
out.push('\n');
}
let command = before_verb_form(argv, flag)
.map_or_else(|| format!("chrome-agent {flag} <value> …"), |line| format!("`{line}`"));
out.push_str(&format!(
"hint: {flag} is read before the verb: {command}. Same values, same command — only the \
flag moves. ({why}.)\n"
));
out
}
fn before_verb_form(argv: &[String], flag: &str) -> Option<String> {
let with_equals = format!("{flag}=");
let position = argv.iter().position(|arg| arg == flag || arg.starts_with(&with_equals))?;
let taken = if argv[position].starts_with(&with_equals) { 1 } else { 2 };
let moved: Vec<&str> = argv[position..]
.iter()
.take(taken)
.map(String::as_str)
.collect();
let rest = argv
.iter()
.enumerate()
.filter(|(i, _)| *i != 0 && (*i < position || *i >= position + taken))
.map(|(_, arg)| arg.as_str());
Some(
std::iter::once("chrome-agent")
.chain(moved)
.chain(rest)
.collect::<Vec<_>>()
.join(" "),
)
}
#[must_use]
pub fn no_download_hint(
browser: &str,
waited_secs: u64,
dispatched: &crate::hit_test::Dispatched,
) -> String {
let run = invocation(browser);
let cause = match dispatched.receiver.as_ref() {
Some(receiver) => format!(
"The click was delivered, but {} occupied the point it was aimed at, so that element \
received it and the element you named never did. ",
receiver.describe()
),
None => String::new(),
};
format!(
"{cause}Chrome reported no download beginning in the {waited_secs}s after the click, so \
nothing was written to disk. Do not click again: the first click reached the page, and \
the page has no way to tell a retry from a second deliberate action — on an export or a \
purchase that is two of them. Run `{run} inspect --urls` to see what the click did \
instead and whether the element is a plain link; when it is, its href downloads with no \
click at all."
)
}
#[must_use]
pub fn undispatched_download_hint(browser: &str) -> String {
let run = invocation(browser);
format!(
"No mouse event was dispatched, so the page is exactly as it was and this click may be \
repeated. The aim point was still moving, outside the viewport, or nowhere inside the \
element's own boxes — an animated scroll and a zero-size element are what that looks \
like. Run `{run} inspect` to see where the element actually sits, then aim again."
)
}
#[must_use]
pub fn download_cap_hint(browser: &str, max_bytes: usize) -> String {
let run = invocation(browser);
format!(
"The transfer passed the {max_bytes}-byte ceiling this invocation set, so it was \
cancelled and the partial file removed. Raise the ceiling and click once more only if \
you accept a second click on the page: `{run} download --max-bytes {} …` with the same \
target. Nothing partial was kept.",
max_bytes.saturating_mul(4)
)
}
#[must_use]
pub fn download_cancelled_hint(browser: &str) -> String {
let run = invocation(browser);
format!(
"Chrome ended the transfer before it finished, which is what a server closing the \
connection, a blocked file type and a revoked blob URL all look like from here. Do not \
click again blind: the first click landed. Run `{run} console` to read what the page \
logged while the download was running."
)
}
#[must_use]
pub fn download_unfinished_hint(browser: &str) -> String {
let run = invocation(browser);
format!(
"The transfer was still running when the wait ended, so the bytes on disk were a prefix \
of the file and were discarded rather than handed back as one. The download itself was \
real: raise the wait instead of clicking again, with `{run} --timeout 300 download` and \
the same target."
)
}
#[must_use]
pub fn intercepted_refusal_hint(
browser: &str,
receiver: Option<&crate::hit_test::Hit>,
mode: crate::hit_test::OnIntercept,
) -> String {
let run = invocation(browser);
let who = receiver.map_or_else(|| "Another element".to_string(), crate::hit_test::Hit::describe);
if receiver.is_some_and(|hit| hit.modal) {
return format!(
"{who} is a modal dialog: it holds the top layer, so every pointer event outside it \
goes to it, and nothing was dispatched here. Run `{run} press Escape` to close it, \
then repeat this action — the page saw no event from this command, so the repeat \
duplicates nothing. Repeating it while the dialog is open produces this same \
refusal."
);
}
let because = match mode {
crate::hit_test::OnIntercept::Guard => {
"--on-intercept guard judged it a control rather than static content"
}
_ => "--on-intercept refuse was set",
};
format!(
"{who} occupies the point this action would have been aimed at, and {because}, so \
nothing was dispatched and the page is exactly as it was. Do not repeat this command \
while that element is there: it will refuse identically. Run `{run} inspect` to find \
that element's own dismiss control, act on it, and aim here again once it is gone."
)
}
fn navigation_failure(msg: &str, run: &str) -> String {
let url = failed_url(msg);
let code = msg.split("net::").nth(1).map_or("", str::trim);
let host = url.and_then(|url| crate::landing::host_and_path(url).map(|(host, _)| host));
let origin = url.and_then(crate::landing::origin_of);
let named = host.unwrap_or("the host");
match code {
"ERR_NAME_NOT_RESOLVED" => {
let alternative = match (host, url) {
(Some(host), Some(url)) if !host.starts_with("www.") => {
format!(
" If it is missing a subdomain, `{run} goto {}` is the usual form.",
url.replacen(host, &format!("www.{host}"), 1)
)
}
_ => String::new(),
};
format!(
"DNS returned no address for `{named}`, so no connection was opened and \
nothing about the site is known — this is the name, not the path, not the \
server.{alternative} If the name is right as typed, the domain publishes no \
address and no flag in this tool changes that."
)
}
"ERR_CONNECTION_REFUSED" => {
let plain = match url {
Some(url) if url.starts_with("https://") => format!(
" `goto` prefixes `https://` when the URL carries no scheme — if that \
server speaks plain HTTP, `{run} goto {}` is the same address with the \
scheme it actually serves.",
url.replacen("https://", "http://", 1)
),
_ => String::new(),
};
format!(
"`{named}` resolved and then refused the connection: the name exists and \
nothing is listening behind it at that port.{plain}"
)
}
code if code.starts_with("ERR_CERT_") => format!(
"Chrome rejected the TLS certificate `{named}` presented ({code}), so the \
connection was dropped before any HTTP request was sent and no page exists to \
read. That certificate is the site's, not this tool's: no chrome-agent flag \
makes Chrome accept it, and navigating again gets the same rejection. The \
hostname the certificate does name is the one to navigate to."
),
"ERR_CONNECTION_RESET" | "ERR_EMPTY_RESPONSE" => format!(
"The connection to `{named}` opened and was then closed with no response ({code}): \
the request may have reached the server, the answer did not come back. That is \
what a proxy or a filter dropping the connection looks like and what a server \
closing mid-response looks like, and nothing here tells them apart. No document \
was served, so there is nothing to inspect and nothing this tool can read."
),
"ERR_HTTP_RESPONSE_CODE_FAILURE" => {
let root = origin.unwrap_or_else(|| "the site root".to_string());
format!(
"The server answered and Chrome refused to render what it sent ({code}) — an \
error status with a body it will not display. The request reached the server, \
so the host and the network are fine and the path or the authorisation is \
not. Run `{run} goto {root}` to reach the site root, where a path that does \
serve a document can be found."
)
}
"ERR_UNSAFE_PORT" => format!(
"Chrome refuses to connect to this port at all ({code}): it keeps a fixed list of \
ports it will not open — 9, 87, 6000 and about eighty others — and the refusal \
happens before any connection is attempted, whatever is listening there. Move \
the service to a port outside that list; no flag in this tool overrides it."
),
"ERR_ABORTED" => format!(
"The navigation was cancelled before it produced a document ({code}). A download \
the page started, a script navigating elsewhere, and a redirect to a scheme \
Chrome hands to another application all end this way. Run `{run} inspect` to see \
which document the page is holding now."
),
code if code.starts_with("ERR_") => {
let root = origin.unwrap_or_else(|| "the site root".to_string());
format!(
"Chrome refused this navigation with {code} before any document existed, so \
nothing about the page is known and there is nothing to inspect. The code is \
Chromium's own and names the stage that failed. Run `{run} goto {root}` to \
see whether the host answers at all."
)
}
_ => format!(
"Chrome refused this navigation and gave no `net::` code this version recognises, \
so which stage failed — DNS, connection, TLS, HTTP — is not known from here. Run \
`{run} status` to confirm the browser is the one this session recorded."
),
}
}
fn failed_url(msg: &str) -> Option<&str> {
let rest = msg.split("Navigation failed for ").nth(1)?;
let end = rest.find(": ").unwrap_or(rest.len());
(end > 0).then(|| &rest[..end])
}
#[must_use]
pub fn error_hint(msg: &str, browser: &str) -> Option<String> {
let run = invocation(browser);
if msg.contains("Failed to connect to page") || msg.contains("DevToolsActivePort") {
Some("Could not attach over CDP. Chrome 136+ disables remote debugging on the default profile: drop --connect to let chrome-agent launch its own dedicated profile, or relaunch your Chrome with a separate --user-data-dir.".to_string())
} else if msg.contains("Connection refused") || msg.contains("No such file") {
Some(format!(
"Nothing answered CDP at the endpoint this session recorded, so the browser it \
named is gone — crashed, or killed from outside this tool. chrome-agent starts \
its own Chrome, so nothing has to be launched by hand: run `{run} close` to drop \
the dead session entry, and the next command opens a fresh browser. If you \
passed --connect, that port has no listener and the Chrome you meant to attach \
to is the thing to start."
))
} else if msg.contains("uid=") && msg.contains("not found") {
Some(format!(
"That uid is not in this page's snapshot: uids change when the document is \
replaced, and `goto` clears the map on purpose. Run `{run} inspect` and act on \
a uid from its output."
))
} else if msg.contains("Navigation failed") {
Some(navigation_failure(msg, &run))
} else if msg.contains("No snapshot") || msg.contains("No inspect") || msg.contains("uid_map is empty") {
Some(format!(
"No snapshot is stored for this page, so there are no uids to resolve and no \
baseline to report a change against. Run `{run} inspect` first: it is what \
creates both."
))
} else if msg.contains("was dispatched and Chrome did not acknowledge it") {
Some(format!(
"The event left this tool and Chrome never confirmed what became of it, so the \
page may have received it and may not. Do not repeat the action: the page cannot \
tell a retry from a second deliberate click, and on a submit or a purchase that is \
two of them. Run `{run} inspect` and read the state the action was supposed to \
produce; act on what you see rather than on what was intended. A pointer event \
answers in milliseconds on a healthy page — a page that is mid-navigation, or a \
renderer that has stopped answering, is what this looks like."
))
} else if msg.contains("Timeout") || msg.contains("timeout") {
Some("Use --timeout N for slow pages".to_string())
} else if msg.contains("not interactable") || msg.contains("no visible box model") {
Some(match uid_in(msg) {
Some(uid) => format!(
"This element has no box on screen, so there is no point to aim at and \
nothing was dispatched. That is what `display:none`, a zero-size box, and \
an element scrolled out of a clipped container all look like. Run `{run} \
scroll {uid}` to bring it into view, then repeat the action; if it stays \
unaimable, the element is not rendered and no coordinate will reach it."
),
None => format!(
"This element has no box on screen, so there is no point to aim at and \
nothing was dispatched. That is what `display:none`, a zero-size box, and \
an element scrolled out of a clipped container all look like. Run `{run} \
inspect` to find the element that is actually rendered and act on that."
),
})
} else if msg.contains("No element matches selector") {
Some(format!(
"The selector matched nothing in the live document. Run `{run} eval \
\"document.querySelectorAll('…').length\"` with your selector to see what it \
matches before acting through it."
))
} else if msg.contains("no resolvable backend node") {
Some(format!(
"This uid names an accessibility node with no DOM element behind it — the `e…` \
uids in a snapshot are Chrome's own generated nodes, and they cannot be resolved \
to something clickable. Run `{run} inspect` and act on a uid beginning with `n`; \
when the thing you want exists only as a generated node, aim at its DOM owner \
with --selector instead."
))
} else if msg.contains("response parse") {
Some(format!(
"Chrome answered this CDP call in a shape this version could not parse, so the \
command never ran. Run `{run} status` to confirm the browser is the one this \
session recorded; a Chrome much newer or older than the bundled Chromium is the \
usual cause."
))
} else if msg.contains("may not have an article") || msg.contains("Readability") {
Some(format!(
"Readability found no article on this page. Run `{run} text --selector \"main\"` \
for the scoped visible text instead."
))
} else if msg.contains("Provide a uid") || msg.contains("Provide --uid") {
Some("Specify what to target: uid (e.g. n47), --selector \"css\", or --xy x,y".to_string())
} else if msg.contains("bound frame's isolated world") {
Some(format!(
"This checked the bound frame's isolated world, where document.modelContext came \
back undefined — the same blindness `eval` already has for a frame's main-world \
variables, just hitting a property instead. That is NOT proof this frame has no \
tools: a polyfill the frame's own script installed on its main-world document is \
invisible here. Run `{run} frame main` to check the top document instead, or accept \
that a frame's own WebMCP tools cannot currently be confirmed absent from outside it."
))
} else if msg.contains("document.modelContext is undefined") {
Some(format!(
"This page's document.modelContext is undefined, so no WebMCP tool can be listed \
or called here. Either this browser was not launched with --chrome-arg \
--enable-features=WebMCP,WebMCPTesting, or the page registers no polyfill for it. \
--chrome-arg is fixed for the life of a named browser: run `{run} close --purge` \
and relaunch with the flag, or check the page's own script for a document.modelContext \
polyfill."
))
} else if msg.contains("no WebMCP tool named") {
Some(format!(
"That name matched none of this page's registered tools when getTools() was last \
checked. Run `{run} webmcp list` to see the names actually registered — a tool can \
also disappear if the page unregistered it since the last list."
))
} else if msg.contains("not of type 'RegisteredTool'") {
Some(format!(
"WebMCP's executeTool() requires the actual tool object getTools() returned, not a \
bare name — this TypeError is what results from passing one directly. Run \
`{run} webmcp list` to see the registered tools, then `{run} webmcp call` with the \
name from that list; it resolves the tool object for you before calling executeTool()."
))
} else if msg.contains("executeTool") && (msg.contains("is not valid JSON") || msg.contains("Failed to parse input arguments")) {
Some(format!(
"executeTool()'s second argument must be a JSON string, not an object — this is what \
results from passing one directly. Run `{run} webmcp call` instead and give --args \
a JSON object or string; chrome-agent serializes it before it ever reaches executeTool()."
))
} else if msg.contains("Evaluation error") || msg.contains("TypeError") || msg.contains("ReferenceError") || msg.contains("SyntaxError") {
Some("JS error in page context. Check expression syntax. Use --selector to scope to an element.".to_string())
} else if msg.contains("dispatcher task exited") || msg.contains("transport closed") {
Some(format!(
"The connection to Chrome dropped, so whether this command reached the page is \
unknown. Do not repeat it blind: a click or a fill that was already delivered \
becomes a second real one, and the page has no way to tell them apart. Run \
`{run} inspect` and read the state the action was supposed to produce before \
deciding. In pipe mode the session is over — the browser and its page survive it, \
so start a new one."
))
} else if msg.contains("not an <iframe>") || msg.contains("not an <IFRAME>") {
Some("Only <iframe> is supported. For <frame>/<frameset>, use eval to access frame content.".to_string())
} else if msg.contains("No child frame found") {
Some("Iframe not found. Check the selector matches an <iframe> element.".to_string())
} else if msg.contains("not a <select>") {
Some("Element is not a <select>. For custom dropdowns, click to open then click the option.".to_string())
} else if msg.contains("No option matching") {
Some("No dropdown option matched. Use inspect --uid to check available options, or try the visible text.".to_string())
} else if msg.contains("File not found") {
Some("Check the file path exists on disk.".to_string())
} else if msg.contains("invalid regular expression") {
Some("--matches takes a Rust regex (regex-lite): \\d \\w \\s are ASCII-only, and there is no \\p{...} or lookaround. For a plain substring use --contains.".to_string())
} else if msg.contains("expected a JSON array") {
Some("Batch expects a JSON array of commands on stdin: [{\"cmd\":\"inspect\"}, ...]".to_string())
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
const RECOGNISED: &[&str] = &[
"Failed to connect to page after 8 attempts: Connection refused",
"DevToolsActivePort file doesn't exist",
"Connection refused",
"No such file or directory",
"Element uid=n5 not found. Run 'chrome-agent inspect' to get fresh uids.",
"Navigation failed for https://akamai.net: net::ERR_NAME_NOT_RESOLVED",
"Navigation failed for https://localhost:3000/a: net::ERR_CONNECTION_REFUSED",
"Navigation failed for https://wrong.host.test/: net::ERR_CERT_COMMON_NAME_INVALID",
"Navigation failed for https://x.test/a: net::ERR_CONNECTION_RESET",
"Navigation failed for https://x.test/a: net::ERR_HTTP_RESPONSE_CODE_FAILURE",
"Navigation failed for http://127.0.0.1:9/x: net::ERR_UNSAFE_PORT",
"Navigation failed for https://x.test/a: net::ERR_ABORTED",
"Navigation failed for https://x.test/a: net::ERR_SOCKS_CONNECTION_FAILED",
"Navigation failed for https://x.test/a: something with no code",
"Navigation failed",
"No snapshot stored for this page",
"uid_map is empty",
"Timeout waiting for selector",
"Input.dispatchMouseEvent was dispatched and Chrome did not acknowledge it within 8s, so what the page did with it is unknown. The event may already have reached the page.",
"Element uid=n47 has no visible box model.",
"Refused to click uid=n47: not interactable",
"not interactable",
"No element matches selector: .missing",
"Element uid=e12 has no resolvable backend node.",
"response parse: invalid type",
"Page may not have an article structure",
"Readability failed",
"Provide a uid, --selector, or --xy to identify the click target.",
"Evaluation error: TypeError: foo",
"dispatcher task exited",
"transport closed",
"Element is not an <iframe>",
"No child frame found for selector",
"Element is not a <select>",
"No option matching: foo",
"File not found: /tmp/nope",
"assert: invalid regular expression",
"batch: expected a JSON array",
"Evaluation error: Error: chrome-agent: document.modelContext is undefined on this page.",
"Evaluation error: Error: chrome-agent: document.modelContext is undefined in the bound frame's isolated world — this does not prove the frame has no tools, since a polyfill the frame's own main-world script installs is invisible here.",
"Evaluation error: Error: chrome-agent: no WebMCP tool named \"foo\". Known tools: bar, baz.",
"Evaluation error: TypeError: The provided value is not of type 'RegisteredTool'.",
"Evaluation error: SyntaxError: \"[object Object]\" is not valid JSON\n at JSON.parse (<anonymous>)\n at Object.executeTool (file:///x.html:1:1)",
];
#[test]
fn every_recognised_failure_has_a_hint() {
for msg in RECOGNISED {
assert!(error_hint(msg, "default").is_some(), "no hint for {msg}");
}
assert!(error_hint("something random", "default").is_none());
}
#[test]
fn no_hint_hands_back_an_unresolved_placeholder() {
for msg in RECOGNISED {
for browser in ["default", "agent-7"] {
let hint = error_hint(msg, browser).expect("a hint");
for placeholder in ["<uid>", "<url>", "<name>", "<selector>", "<n>"] {
assert!(
!hint.contains(placeholder),
"the hint for {msg:?} hands back {placeholder}: {hint}"
);
}
}
}
}
#[test]
fn no_hint_invites_a_blind_retry() {
for msg in RECOGNISED {
let hint = error_hint(msg, "default").expect("a hint");
for forbidden in [
"Try running the command again",
"try running the command again",
"run the command again",
] {
assert!(
!hint.contains(forbidden),
"the hint for {msg:?} invites a blind retry: {hint}"
);
}
}
}
#[test]
fn a_command_in_a_hint_names_this_invocation_s_browser() {
for msg in RECOGNISED {
let hint = error_hint(msg, "agent-7").expect("a hint");
if !hint.contains("chrome-agent ") {
continue; }
for word in hint.split('`').skip(1).step_by(2) {
if let Some(rest) = word.strip_prefix("chrome-agent ") {
assert!(
rest.starts_with("--browser agent-7 "),
"hint for {msg:?} runs a command against the wrong browser: {word}"
);
}
}
}
}
#[test]
fn the_hint_for_an_unaimable_element_names_the_element() {
let hint = error_hint("Element uid=n47 has no visible box model.", "default")
.expect("a hint");
assert!(hint.contains("chrome-agent scroll n47"), "{hint}");
assert!(!hint.contains("may be hidden"), "{hint}");
}
#[test]
fn uid_extraction_stops_at_the_end_of_the_uid() {
assert_eq!(uid_in("Element uid=n47 has no visible box model."), Some("n47"));
assert_eq!(uid_in("Refused to click uid=n5: covered"), Some("n5"));
assert_eq!(uid_in("Element uid=e12 has no resolvable backend node."), Some("e12"));
assert_eq!(uid_in("no uid here"), None);
assert_eq!(uid_in("trailing uid="), None);
}
#[test]
fn an_unacknowledged_input_forbids_the_retry_rather_than_raising_the_budget() {
let msg = "Input.dispatchMouseEvent was dispatched and Chrome did not acknowledge it \
within 8s, so what the page did with it is unknown. The event may already \
have reached the page.";
let hint = error_hint(msg, "agent-7").expect("a hint");
assert!(hint.contains("Do not repeat the action"), "{hint}");
assert!(hint.contains("`chrome-agent --browser agent-7 inspect`"), "{hint}");
assert!(!hint.contains("--timeout N"), "the generic branch must not swallow this: {hint}");
}
#[test]
fn a_lost_connection_forbids_the_retry_and_says_what_to_read() {
for msg in ["dispatcher task exited", "transport closed"] {
let hint = error_hint(msg, "default").expect("a hint");
assert!(hint.contains("unknown"), "the fact comes first: {hint}");
assert!(hint.contains("Do not repeat it blind"), "{hint}");
assert!(hint.contains("chrome-agent inspect"), "{hint}");
}
}
#[test]
fn a_refused_connection_does_not_ask_whether_chrome_is_running() {
let hint = error_hint("Connection refused", "default").expect("a hint");
assert!(!hint.contains("Is Chrome running"), "{hint}");
assert!(hint.contains("chrome-agent close"), "the recovery is one command: {hint}");
assert!(!hint.contains("136"), "the Chrome 136 branch must not swallow this one: {hint}");
}
#[test]
fn connect_failure_hints_at_chrome_136() {
for msg in [
"Failed to connect to page after 8 attempts: Connection refused",
"DevToolsActivePort file doesn't exist",
] {
let hint = error_hint(msg, "default").expect("connect failure should have a hint");
assert!(hint.contains("136"), "hint should mention Chrome 136: {hint}");
assert!(hint.contains("--connect"), "hint should mention --connect: {hint}");
}
}
#[test]
fn an_unresolvable_node_and_an_unparseable_reply_do_not_share_a_hint() {
let node = error_hint("Element uid=e12 has no resolvable backend node.", "default")
.expect("a hint");
let parse = error_hint("response parse: invalid type", "default").expect("a hint");
assert_ne!(node, parse);
assert!(node.contains("--selector"), "the route past a generated node: {node}");
assert!(parse.contains("CDP"), "the cause has to be named: {parse}");
for hint in [&node, &parse] {
assert!(!hint.contains("Page structure issue"), "{hint}");
}
}
#[test]
fn webmcp_errors_do_not_fall_through_to_the_generic_js_error_hint() {
let generic = "JS error in page context. Check expression syntax. Use --selector to scope to an element.";
let no_context = error_hint(
"Evaluation error: Error: chrome-agent: document.modelContext is undefined on this page.",
"default",
)
.unwrap();
assert_ne!(no_context, generic);
assert!(no_context.contains("--chrome-arg"), "{no_context}");
assert!(no_context.contains("--enable-features=WebMCP"), "{no_context}");
let unknown_tool = error_hint(
"Evaluation error: Error: chrome-agent: no WebMCP tool named \"foo\". Known tools: bar.",
"default",
)
.unwrap();
assert_ne!(unknown_tool, generic);
assert!(unknown_tool.contains("chrome-agent webmcp list"), "{unknown_tool}");
let bare_name = error_hint(
"Evaluation error: TypeError: The provided value is not of type 'RegisteredTool'.",
"default",
)
.unwrap();
assert_ne!(bare_name, generic);
assert!(bare_name.contains("chrome-agent webmcp call"), "{bare_name}");
let object_args = error_hint(
"Evaluation error: SyntaxError: \"[object Object]\" is not valid JSON\n at executeTool (x)",
"default",
)
.unwrap();
assert_ne!(object_args, generic);
assert!(object_args.contains("--args"), "{object_args}");
}
#[test]
fn a_frame_scoped_absence_is_not_read_as_a_plain_absence() {
let plain = error_hint(
"Evaluation error: Error: chrome-agent: document.modelContext is undefined on this page.",
"default",
)
.unwrap();
assert!(plain.contains("--chrome-arg"), "{plain}");
assert!(!plain.contains("bound frame"), "{plain}");
let frame = error_hint(
"Evaluation error: Error: chrome-agent: document.modelContext is undefined in the \
bound frame's isolated world — this does not prove the frame has no tools, since a \
polyfill the frame's own main-world script installs is invisible here.",
"default",
)
.unwrap();
assert_ne!(plain, frame);
assert!(frame.contains("frame main"), "{frame}");
assert!(!frame.contains("--chrome-arg"), "a frame binding is not a launch-flag problem: {frame}");
}
const POSITION_ERROR: &str = "error: unexpected argument '--timeout' found\n\n tip: to pass '--timeout' as a value, use '-- --timeout'\n\nUsage: chrome-agent click <UID>\n\nFor more information, try '--help'.\n";
fn argv(args: &[&str]) -> Vec<String> {
std::iter::once("/path/to/target/debug/chrome-agent")
.chain(args.iter().copied())
.map(String::from)
.collect()
}
#[test]
fn the_misleading_tip_is_replaced_by_the_rule_that_was_broken() {
let out = usage_error(POSITION_ERROR, &argv(&["click", "n1", "--timeout", "5"]));
assert!(!out.contains("-- --timeout"), "clap's tip survived: {out}");
assert!(!out.contains("as a value"), "{out}");
assert!(out.contains("error: unexpected argument '--timeout' found"), "{out}");
assert!(out.contains("Usage: chrome-agent click <UID>"), "{out}");
assert!(out.contains("hint: --timeout is read before the verb"), "{out}");
assert!(!out.contains("\n\n\n"), "removing the tip left a double gap: {out:?}");
}
#[test]
fn the_suggested_command_is_this_invocation_with_the_flag_moved() {
let out = usage_error(POSITION_ERROR, &argv(&["click", "n1", "--timeout", "5"]));
assert!(out.contains("`chrome-agent --timeout 5 click n1`"), "{out}");
assert!(!out.contains("/path/to/target"), "{out}");
}
#[test]
fn a_glued_value_and_the_other_flags_survive_the_reorder() {
let out = usage_error(
POSITION_ERROR,
&argv(&["--browser", "agent-7", "click", "n1", "--timeout=5"]),
);
assert!(
out.contains("`chrome-agent --timeout=5 --browser agent-7 click n1`"),
"{out}"
);
}
#[test]
fn every_other_usage_error_is_returned_untouched() {
for rendered in [
"error: unexpected argument '--nonsense' found\n\n tip: to pass '--nonsense' as a value, use '-- --nonsense'\n",
"error: the following required arguments were not provided:\n <WHAT>\n",
"error: the following required arguments were not provided:\n <WHAT>\n\nUsage: chrome-agent wait <WHAT> [PATTERN] --timeout <TIMEOUT>\n",
] {
assert_eq!(usage_error(rendered, &argv(&["wait"])), rendered, "rewrote {rendered:?}");
}
}
#[test]
fn each_before_verb_flag_explains_itself_and_names_no_placeholder() {
for (flag, why) in crate::cli::BEFORE_VERB_ONLY {
let rendered = format!("error: unexpected argument '{flag}' found\n");
let out = usage_error(&rendered, &argv(&["click", "n1", flag, "2"]));
assert!(out.contains(why), "{flag} lost its reason: {out}");
assert!(out.contains(&format!("`chrome-agent {flag} 2 click n1`")), "{out}");
for placeholder in ["<value>", "<uid>", "<n>"] {
assert!(!out.contains(placeholder), "{flag} hands back {placeholder}: {out}");
}
}
}
fn download_hints(browser: &str) -> Vec<String> {
vec![
no_download_hint(browser, 30, &crate::hit_test::Dispatched::js()),
undispatched_download_hint(browser),
download_cap_hint(browser, 67_108_864),
download_cancelled_hint(browser),
download_unfinished_hint(browser),
]
}
#[test]
fn every_download_hint_holds_to_the_contract() {
for hint in download_hints("agent-7") {
for placeholder in ["<uid>", "<url>", "<name>", "<selector>", "<n>", "<value>"] {
assert!(!hint.contains(placeholder), "hands back {placeholder}: {hint}");
}
for forbidden in ["Try running the command again", "run the command again"] {
assert!(!hint.contains(forbidden), "invites a blind retry: {hint}");
}
for word in hint.split('`').skip(1).step_by(2) {
if let Some(rest) = word.strip_prefix("chrome-agent ") {
assert!(
rest.starts_with("--browser agent-7 "),
"command aimed at the wrong browser: {word}"
);
}
}
}
}
#[test]
fn a_delivered_click_forbids_the_second_one_and_an_undelivered_one_permits_it() {
let dispatched = [
no_download_hint("default", 30, &crate::hit_test::Dispatched::js()),
download_cancelled_hint("default"),
];
for hint in dispatched {
assert!(
hint.contains("Do not click again") || hint.contains("do not click again"),
"a delivered click must forbid the retry: {hint}"
);
}
let safe = undispatched_download_hint("default");
assert!(safe.contains("may be repeated"), "{safe}");
assert!(!safe.contains("Do not click again"), "{safe}");
let unfinished = download_unfinished_hint("default");
assert!(unfinished.contains("raise the wait instead of clicking again"), "{unfinished}");
}
#[test]
fn an_intercepted_click_names_its_receiver_in_the_hint() {
let plain = no_download_hint("default", 5, &crate::hit_test::Dispatched::js());
assert!(!plain.contains("occupied the point"), "{plain}");
assert!(plain.contains("5s"), "the window is the fact: {plain}");
}
#[test]
fn the_refusal_hint_names_the_receiver_and_this_browser() {
let receiver = crate::hit_test::Hit {
tag: "DIV".into(),
id: Some("gdpr-wall".into()),
cls: Some("wall".into()),
z: Some("9999".into()),
text: "We use cookies".into(),
modal: false,
iframe: false,
same_doc: true,
actionable: true,
uid: Some("n210".into()),
};
let hint = intercepted_refusal_hint("agent-7", Some(&receiver), crate::hit_test::OnIntercept::Refuse);
assert!(hint.starts_with("div#gdpr-wall.wall"), "rule 1, the fact first: {hint}");
assert!(
hint.contains("`chrome-agent --browser agent-7 inspect`"),
"rule 2, one command, on this invocation's browser: {hint}"
);
assert!(
hint.contains("Do not repeat this command while that element is there"),
"rule 3: the retry here is futile rather than dangerous, and has to be refused in \
words or an agent will spin on it: {hint}"
);
for placeholder in ["<uid>", "<selector>", "<name>"] {
assert!(!hint.contains(placeholder), "{hint}");
}
}
#[test]
fn a_modal_receiver_gets_the_dismissal_a_modal_actually_has() {
let mut dialog = crate::hit_test::Hit {
tag: "DIALOG".into(),
id: Some("terms".into()),
cls: None,
z: None,
text: "Terms".into(),
modal: true,
iframe: false,
same_doc: true,
actionable: true,
uid: None,
};
let refuse = crate::hit_test::OnIntercept::Refuse;
let modal = intercepted_refusal_hint("default", Some(&dialog), refuse);
assert!(modal.contains("`chrome-agent press Escape`"), "{modal}");
dialog.modal = false;
assert_ne!(modal, intercepted_refusal_hint("default", Some(&dialog), refuse));
let anonymous = intercepted_refusal_hint("default", None, refuse);
assert!(anonymous.starts_with("Another element"), "{anonymous}");
assert!(anonymous.contains("chrome-agent inspect"), "{anonymous}");
}
#[test]
fn a_guard_refusal_names_its_own_reason_not_the_callers_choice() {
let receiver = crate::hit_test::Hit {
tag: "BUTTON".into(),
id: None,
cls: Some("Cmp__action Cmp__action--yes".into()),
z: None,
text: "oui, j'accepte".into(),
modal: false,
iframe: false,
same_doc: true,
actionable: true,
uid: Some("n42".into()),
};
let guard = intercepted_refusal_hint(
"default",
Some(&receiver),
crate::hit_test::OnIntercept::Guard,
);
let refuse = intercepted_refusal_hint(
"default",
Some(&receiver),
crate::hit_test::OnIntercept::Refuse,
);
assert_ne!(guard, refuse);
assert!(guard.contains("judged it a control"), "{guard}");
assert!(!guard.contains("refuse was set"), "{guard}");
assert!(refuse.contains("refuse was set"), "{refuse}");
}
#[test]
fn each_navigation_failure_states_the_stage_that_failed() {
let cases = [
("net::ERR_NAME_NOT_RESOLVED", "DNS"),
("net::ERR_CONNECTION_REFUSED", "refused the connection"),
("net::ERR_CERT_COMMON_NAME_INVALID", "TLS certificate"),
("net::ERR_CONNECTION_RESET", "no response"),
("net::ERR_HTTP_RESPONSE_CODE_FAILURE", "refused to render"),
];
let mut seen: Vec<String> = Vec::new();
for (code, fact) in cases {
let hint = error_hint(
&format!("Navigation failed for https://site.test/a: {code}"),
"default",
)
.expect("a hint");
assert!(hint.contains(fact), "{code} does not state its stage: {hint}");
assert!(
!hint.contains("Check the URL is valid"),
"the one-size sentence survived: {hint}"
);
assert!(!seen.contains(&hint), "two causes share a hint: {hint}");
seen.push(hint);
}
}
#[test]
fn a_navigation_failure_names_the_host_it_could_not_reach() {
let hint = error_hint(
"Navigation failed for https://akamai.net: net::ERR_NAME_NOT_RESOLVED",
"default",
)
.expect("a hint");
assert!(hint.contains("akamai.net"), "{hint}");
assert!(hint.contains("chrome-agent goto https://www.akamai.net"), "{hint}");
assert!(hint.contains("If it is missing a subdomain"), "the criterion: {hint}");
}
#[test]
fn a_refused_connection_offers_the_scheme_goto_did_not_choose() {
let hint = error_hint(
"Navigation failed for https://localhost:3000/a: net::ERR_CONNECTION_REFUSED",
"agent-7",
)
.expect("a hint");
assert!(
hint.contains("`chrome-agent --browser agent-7 goto http://localhost:3000/a`"),
"{hint}"
);
let plain = error_hint(
"Navigation failed for http://localhost:3000/a: net::ERR_CONNECTION_REFUSED",
"default",
)
.expect("a hint");
assert!(!plain.contains("goto http://"), "nothing to change: {plain}");
}
#[test]
fn the_two_failures_with_no_recovery_say_so_instead_of_guessing() {
let cert = error_hint(
"Navigation failed for https://x.test/a: net::ERR_CERT_COMMON_NAME_INVALID",
"default",
)
.expect("a hint");
assert!(cert.contains("no chrome-agent flag makes Chrome accept it"), "{cert}");
let reset = error_hint(
"Navigation failed for https://x.test/a: net::ERR_CONNECTION_RESET",
"default",
)
.expect("a hint");
assert!(reset.contains("nothing to inspect"), "{reset}");
assert!(reset.contains("may have reached the server"), "{reset}");
}
#[test]
fn an_unrecognised_code_is_reported_as_itself() {
let unknown = error_hint(
"Navigation failed for https://x.test:8443/a: net::ERR_SOCKS_CONNECTION_FAILED",
"default",
)
.expect("a hint");
assert!(unknown.contains("ERR_SOCKS_CONNECTION_FAILED"), "{unknown}");
assert!(unknown.contains("chrome-agent goto https://x.test:8443/"), "the port survives: {unknown}");
let codeless = error_hint("Navigation failed", "default").expect("a hint");
assert!(codeless.contains("no `net::` code"), "{codeless}");
}
#[test]
fn the_failed_url_is_read_out_of_the_message() {
assert_eq!(
failed_url("Navigation failed for https://x.test/a: net::ERR_ABORTED"),
Some("https://x.test/a")
);
assert_eq!(failed_url("Navigation failed"), None);
}
#[test]
fn the_missing_snapshot_hint_says_why_inspect_is_needed() {
let hint = error_hint("No snapshot stored for this page", "default").expect("a hint");
assert!(hint.contains("chrome-agent inspect"), "{hint}");
assert!(hint.contains("uids"), "no snapshot means no uids: {hint}");
assert!(hint.contains("baseline"), "and no baseline to compare against: {hint}");
}
}