//! `chrome-use test ` — a tiny, re-runnable browser test runner. //! //! Turns repetitive browser checks into unit-test-style suites for the frontend. //! A suite is a YAML file of cases; each case is a list of `steps` (which reuse //! chrome-use's own commands) followed by `assert`s (which compile to a single //! `eval` expression read back as a boolean). The runner drives the session by //! re-invoking the chrome-use binary per step, so it inherits every flag / //! launch / daemon / `@ref` semantic for free; the daemon stays up for the //! session, so each step is just a fast socket round-trip. //! //! ```yaml //! suite: chatgpt smoke //! setup: //! - account: chatgpt/huayue # cookie-use injects this login (optional) //! cases: //! - name: home loads logged in //! steps: //! - open: https://chatgpt.com/ //! - wait: { load: networkidle } //! assert: //! - url: { contains: chatgpt.com } //! - visible: "#prompt-textarea" //! ``` use crate::flags::Flags; use serde_json::Value; use std::process::Command; use std::time::Instant; pub fn run_test(suite_path: &str, flags: &Flags) -> i32 { let text = match std::fs::read_to_string(suite_path) { Ok(t) => t, Err(e) => { eprintln!("{} cannot read suite '{}': {}", err(), suite_path, e); return 2; } }; // YAML deserializes straight into serde_json::Value (maps→objects, etc.). let suite: Value = match serde_yaml::from_str(&text) { Ok(v) => v, Err(e) => { eprintln!("{} invalid YAML in '{}': {}", err(), suite_path, e); return 2; } }; let cases = match suite.get("cases").and_then(|c| c.as_array()) { Some(c) if !c.is_empty() => c.clone(), _ => { eprintln!("{} suite has no `cases`", err()); return 2; } }; let suite_name = suite .get("suite") .and_then(|s| s.as_str()) .unwrap_or("suite"); let exe = match std::env::current_exe() { Ok(p) => p.to_string_lossy().into_owned(), Err(e) => { eprintln!("{} cannot find own binary: {}", err(), e); return 2; } }; // A dedicated launched browser by default (deterministic, re-runnable). If // the user named a --session, target that existing one instead. let (session, do_launch) = if flags.session == "default" { ("cu-test".to_string(), true) } else { (flags.session.clone(), flags.force_launch) }; let owns_session = session == "cu-test"; let mut base: Vec = vec!["--session".into(), session.clone()]; if do_launch { base.push("--launch".into()); } if let Some(p) = &flags.profile { base.push("--profile".into()); base.push(p.clone()); } let artifacts_dir = flags .download_path .clone() .unwrap_or_else(|| "cu-test-artifacts".to_string()); let runner = Runner { exe, base, artifacts_dir, }; // --- setup (runs once) --- if let Some(setup) = suite.get("setup").and_then(|s| s.as_array()) { for item in setup { if let Err(e) = runner.run_setup_item(item, &session) { eprintln!("{} setup failed: {}", err(), e); if owns_session { runner.close(); } return 2; } } } // --- cases --- println!("suite: {} (session {})", suite_name, session); let mut passed = 0usize; let mut failed = 0usize; for case in &cases { let name = case .get("name") .and_then(|n| n.as_str()) .unwrap_or("(unnamed)"); let start = Instant::now(); let outcome = runner.run_case(case); let secs = start.elapsed().as_secs_f64(); match outcome { Ok(()) => { passed += 1; println!(" {} {} {:.1}s", ok(), name, secs); } Err(failure) => { failed += 1; println!(" {} {} {:.1}s", cross(), name, secs); println!(" {}", failure.reason); if let Some(shot) = runner.capture_artifact(name) { println!(" ↳ {}", shot); } } } } if owns_session { runner.close(); } println!( "{} cases · {} passed · {} failed", cases.len(), passed, failed ); i32::from(failed > 0) } struct Failure { reason: String, } struct Runner { exe: String, base: Vec, artifacts_dir: String, } impl Runner { /// Run one chrome-use sub-command. Returns the `data` object on success. fn cli(&self, args: &[String]) -> Result, String> { let out = Command::new(&self.exe) .args(&self.base) .args(args) .arg("--json") .output() .map_err(|e| format!("spawning chrome-use: {}", e))?; let stdout = String::from_utf8_lossy(&out.stdout); if let Ok(v) = serde_json::from_str::(stdout.trim()) { let success = v .get("success") .and_then(|b| b.as_bool()) .unwrap_or(out.status.success()); if !success { return Err(v .get("error") .and_then(|e| e.as_str()) .unwrap_or("command failed") .to_string()); } return Ok(v.get("data").cloned()); } if out.status.success() { Ok(None) } else { Err(String::from_utf8_lossy(&out.stderr).trim().to_string()) } } fn close(&self) { let _ = self.cli(&["close".to_string()]); } fn run_setup_item(&self, item: &Value, session: &str) -> Result<(), String> { // `account: ` injects a stored cookie-use login into this session. if let Some(acct) = item.get("account").and_then(|a| a.as_str()) { let target = format!("session:{}", session); let out = Command::new("cookie-use") .args(["use", acct, "--target", &target, "--no-open"]) .output(); return match out { Ok(o) if o.status.success() => Ok(()), Ok(o) => Err(format!( "cookie-use use {} failed: {}", acct, String::from_utf8_lossy(&o.stderr).trim() )), Err(e) => Err(format!( "cookie-use not available ({}); skip `account:` or install it", e )), }; } // Otherwise it's a normal step. let args = step_to_args(item)?; self.cli(&args).map(|_| ()) } fn run_case(&self, case: &Value) -> Result<(), Failure> { if let Some(steps) = case.get("steps").and_then(|s| s.as_array()) { for step in steps { let args = step_to_args(step).map_err(|e| Failure { reason: format!("bad step: {}", e), })?; self.cli(&args).map_err(|e| Failure { reason: format!( "step `{}` failed: {}", args.first().cloned().unwrap_or_default(), e ), })?; } } if let Some(asserts) = case.get("assert").and_then(|a| a.as_array()) { for a in asserts { let (expr, describe) = assert_to_eval(a).map_err(|e| Failure { reason: format!("bad assert: {}", e), })?; let data = self.cli(&["eval".to_string(), expr]).map_err(|e| Failure { reason: format!("assert `{}` could not run: {}", describe, e), })?; let result = data.as_ref().and_then(|d| d.get("result")); if !is_truthy(result) { let got = result .map(value_short) .unwrap_or_else(|| "undefined".into()); return Err(Failure { reason: format!("assert {} → got {}", describe, got), }); } } } Ok(()) } /// Best-effort screenshot of the failing state. Returns the saved path. fn capture_artifact(&self, case_name: &str) -> Option { let _ = std::fs::create_dir_all(&self.artifacts_dir); let path = format!("{}/{}.png", self.artifacts_dir, slug(case_name)); match self.cli(&["screenshot".to_string(), path.clone()]) { Ok(Some(d)) => d .get("path") .and_then(|p| p.as_str()) .map(String::from) .or(Some(path)), Ok(None) => Some(path), Err(_) => None, } } } /// Map a YAML step (a one-key object) to chrome-use CLI args. fn step_to_args(step: &Value) -> Result, String> { let obj = step .as_object() .ok_or_else(|| "step must be a key: value mapping".to_string())?; let (key, val) = obj.iter().next().ok_or_else(|| "empty step".to_string())?; let s = |v: &Value| v.as_str().map(String::from); match key.as_str() { "open" | "goto" | "navigate" => { let url = s(val).ok_or("open: expected a URL string")?; Ok(vec!["open".into(), url]) } "click" => Ok(vec![ "click".into(), s(val).ok_or("click: expected a selector")?, ]), "press" => Ok(vec!["press".into(), s(val).ok_or("press: expected a key")?]), "eval" => Ok(vec![ "eval".into(), s(val).ok_or("eval: expected JS string")?, ]), "fill" | "type" => { let sel = field(val, &["sel", "selector"]).ok_or("fill/type: need sel")?; let text = field(val, &["text", "value"]).ok_or("fill/type: need text")?; Ok(vec![key.clone(), sel, text]) } "scroll" => { if let Some(dir) = s(val) { Ok(vec!["scroll".into(), dir]) } else { let dir = field(val, &["dir", "direction"]).ok_or("scroll: need dir")?; let mut a = vec!["scroll".into(), dir]; if let Some(px) = field(val, &["px", "pixels"]) { a.push(px); } Ok(a) } } "wait" => { if let Some(n) = val.as_i64() { Ok(vec!["wait".into(), n.to_string()]) } else if let Some(load) = field(val, &["load"]) { Ok(vec!["wait".into(), "--load".into(), load]) } else if let Some(sel) = s(val) { Ok(vec!["wait".into(), sel]) } else { Err("wait: expected ms, a selector, or { load: }".into()) } } other => Err(format!("unknown step `{}`", other)), } } /// Compile a YAML assert (one-key object) into (js-bool-expr, human-describe). fn assert_to_eval(a: &Value) -> Result<(String, String), String> { let obj = a .as_object() .ok_or_else(|| "assert must be a key: value mapping".to_string())?; let (key, val) = obj .iter() .next() .ok_or_else(|| "empty assert".to_string())?; match key.as_str() { "url" => { let (op, want) = str_op(val).ok_or("url: need contains/equals/matches")?; Ok(( cmp_expr("location.href", &op, &want), format!("url {} {:?}", op, want), )) } "visible" => { let sel = val.as_str().ok_or("visible: expected a selector")?; Ok((visible_expr(sel), format!("visible {:?}", sel))) } "hidden" => { let sel = val.as_str().ok_or("hidden: expected a selector")?; Ok(( format!("!({})", visible_expr(sel)), format!("hidden {:?}", sel), )) } "text" => { let sel = field(val, &["sel", "selector"]).ok_or("text: need sel")?; let (op, want) = str_op(val).ok_or("text: need contains/equals/matches")?; let base = format!( "((document.querySelector({})||{{}}).textContent||\"\")", js(&sel) ); Ok(( cmp_expr(&base, &op, &want), format!("text {:?} {} {:?}", sel, op, want), )) } "count" => { let sel = field(val, &["sel", "selector"]).ok_or("count: need sel")?; let n = val .get("eq") .or_else(|| val.get("equals")) .and_then(|v| v.as_i64()) .ok_or("count: need eq: ")?; Ok(( format!("document.querySelectorAll({}).length==={}", js(&sel), n), format!("count {:?} == {}", sel, n), )) } "eval" => { let expr = val.as_str().ok_or("eval: expected JS string")?; Ok((format!("!!({})", expr), format!("eval {:?}", expr))) } other => Err(format!("unknown assert `{}`", other)), } } fn visible_expr(sel: &str) -> String { format!( "(function(){{var e=document.querySelector({});return !!(e&&(e.offsetWidth||e.offsetHeight||e.getClientRects().length));}})()", js(sel) ) } /// Extract (op, want) from `{contains|equals|matches: }`. fn str_op(val: &Value) -> Option<(String, String)> { for op in ["contains", "equals", "matches"] { if let Some(s) = val.get(op).and_then(|v| v.as_str()) { return Some((op.to_string(), s.to_string())); } } None } fn cmp_expr(base: &str, op: &str, want: &str) -> String { match op { "equals" => format!("({})==={}", base, js(want)), "matches" => format!("new RegExp({}).test({})", js(want), base), _ => format!("({}).includes({})", base, js(want)), // contains } } /// First present field among `keys`, as a string. fn field(val: &Value, keys: &[&str]) -> Option { for k in keys { if let Some(v) = val.get(*k) { return match v { Value::String(s) => Some(s.clone()), Value::Number(n) => Some(n.to_string()), Value::Bool(b) => Some(b.to_string()), _ => None, }; } } None } /// JSON-encode a string so it embeds safely as a JS literal. fn js(s: &str) -> String { serde_json::to_string(s).unwrap_or_else(|_| "\"\"".into()) } fn is_truthy(v: Option<&Value>) -> bool { match v { Some(Value::Bool(b)) => *b, Some(Value::Null) | None => false, Some(Value::Number(n)) => n.as_f64().map(|f| f != 0.0).unwrap_or(false), Some(Value::String(s)) => !s.is_empty(), Some(_) => true, } } fn value_short(v: &Value) -> String { let s = v.to_string(); if s.len() > 60 { format!("{}…", &s[..60]) } else { s } } fn slug(name: &str) -> String { let s: String = name .chars() .map(|c| if c.is_alphanumeric() { c } else { '-' }) .collect(); s.trim_matches('-').to_lowercase() } #[cfg(test)] mod tests { use super::*; use serde_json::json; #[test] fn step_mapping() { assert_eq!( step_to_args(&json!({"open": "https://x.com"})).unwrap(), vec!["open", "https://x.com"] ); assert_eq!( step_to_args(&json!({"fill": {"sel": "#a", "text": "hi"}})).unwrap(), vec!["fill", "#a", "hi"] ); assert_eq!( step_to_args(&json!({"wait": {"load": "networkidle"}})).unwrap(), vec!["wait", "--load", "networkidle"] ); assert_eq!( step_to_args(&json!({"wait": 500})).unwrap(), vec!["wait", "500"] ); assert!(step_to_args(&json!({"bogus": 1})).is_err()); } #[test] fn assert_compilation() { let (e, _) = assert_to_eval(&json!({"url": {"contains": "x.com"}})).unwrap(); assert!(e.contains("location.href") && e.contains(".includes(")); let (e, _) = assert_to_eval(&json!({"count": {"sel": ".a", "eq": 3}})).unwrap(); assert!(e.contains("querySelectorAll") && e.ends_with("===3")); let (e, _) = assert_to_eval(&json!({"hidden": "#x"})).unwrap(); assert!(e.starts_with("!(")); let (e, _) = assert_to_eval(&json!({"eval": "window.ok"})).unwrap(); assert_eq!(e, "!!(window.ok)"); assert!(assert_to_eval(&json!({"bogus": 1})).is_err()); } #[test] fn truthiness() { assert!(is_truthy(Some(&json!(true)))); assert!(!is_truthy(Some(&json!(false)))); assert!(!is_truthy(None)); assert!(!is_truthy(Some(&json!("")))); assert!(is_truthy(Some(&json!("x")))); assert!(!is_truthy(Some(&json!(0)))); } #[test] fn js_escaping() { // Selectors with quotes must embed safely. assert_eq!(js(r#"a"b"#), r#""a\"b""#); } } fn ok() -> &'static str { "\x1b[32m✓\x1b[0m" } fn cross() -> &'static str { "\x1b[31m✗\x1b[0m" } fn err() -> &'static str { "\x1b[31merror:\x1b[0m" }