#![cfg(feature = "cdp")]
use base64::Engine;
use native_devtools_mcp::cdp::tools::cdp_navigate;
use native_devtools_mcp::cdp::CdpClient;
use rmcp::model::{CallToolResult, Content};
use std::net::TcpListener;
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tempfile::TempDir;
use tokio::sync::RwLock;
pub type ClientHandle = Arc<RwLock<Option<CdpClient>>>;
fn find_chrome_binary() -> Option<PathBuf> {
let candidates: &[&str] = if cfg!(target_os = "macos") {
&[
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
"/Applications/Chromium.app/Contents/MacOS/Chromium",
]
} else if cfg!(target_os = "linux") {
&[
"/usr/bin/google-chrome",
"/usr/bin/google-chrome-stable",
"/usr/bin/chromium",
"/usr/bin/chromium-browser",
"/snap/bin/chromium",
]
} else {
&[]
};
candidates.iter().map(PathBuf::from).find(|p| p.is_file())
}
fn pick_free_port() -> Option<u16> {
let listener = TcpListener::bind("127.0.0.1:0").ok()?;
let port = listener.local_addr().ok()?.port();
drop(listener);
Some(port)
}
pub struct Harness {
chrome: Option<Child>,
_profile: TempDir,
client: ClientHandle,
}
pub enum LaunchOutcome {
Ready(Harness),
NoChrome,
}
impl Harness {
pub async fn launch() -> Result<LaunchOutcome, String> {
let chrome_path = match find_chrome_binary() {
Some(p) => p,
None => {
eprintln!("[harness] skipping: no Chrome/Chromium binary found");
return Ok(LaunchOutcome::NoChrome);
}
};
let profile = TempDir::new().map_err(|e| format!("cannot create temp profile dir: {e}"))?;
let port = pick_free_port().ok_or_else(|| "could not acquire a free port".to_string())?;
let mut cmd = Command::new(&chrome_path);
cmd.arg("--headless=new")
.arg(format!("--remote-debugging-port={port}"))
.arg(format!("--user-data-dir={}", profile.path().display()))
.arg("--no-first-run")
.arg("--no-default-browser-check")
.arg("--disable-gpu")
.arg("--disable-background-networking")
.arg("--disable-sync")
.arg("--disable-default-apps")
.arg("--disable-extensions")
.arg("about:blank")
.stdout(Stdio::null())
.stderr(Stdio::null());
let mut child = cmd
.spawn()
.map_err(|e| format!("failed to spawn Chrome at {chrome_path:?}: {e}"))?;
if let Err(e) = wait_for_debug_port(port, Duration::from_secs(15)).await {
let _ = child.kill();
return Err(format!("Chrome never opened debug port {port}: {e}"));
}
let client = match CdpClient::connect(port).await {
Ok(c) => c,
Err(e) => {
let _ = child.kill();
return Err(format!("CdpClient::connect(port={port}) failed: {e}"));
}
};
Ok(LaunchOutcome::Ready(Self {
chrome: Some(child),
_profile: profile,
client: Arc::new(RwLock::new(Some(client))),
}))
}
pub async fn launch_or_skip() -> Option<Self> {
match Self::launch().await {
Ok(LaunchOutcome::Ready(h)) => Some(h),
Ok(LaunchOutcome::NoChrome) => None,
Err(e) => panic!("harness launch failed: {e}"),
}
}
pub fn client_handle(&self) -> ClientHandle {
self.client.clone()
}
pub async fn navigate(&mut self, html: &str) {
let b64 = base64::engine::general_purpose::STANDARD.encode(html);
let url = format!("data:text/html;base64,{b64}");
let result = cdp_navigate(Some(url), None, Some(10_000), self.client_handle()).await;
assert_eq!(
result.is_error,
Some(false),
"navigate failed: {}",
content_text(&result)
);
self.wait_for_ready(Duration::from_secs(5)).await;
}
async fn wait_for_ready(&self, timeout: Duration) {
let start = Instant::now();
loop {
if self.eval_bool("document.readyState === 'complete'").await {
return;
}
if start.elapsed() >= timeout {
panic!(
"page did not reach readyState=complete within {:?}",
timeout
);
}
tokio::time::sleep(Duration::from_millis(25)).await;
}
}
pub async fn eval_bool(&self, expr: &str) -> bool {
use native_devtools_mcp::cdp::tools::cdp_evaluate_script;
let r = cdp_evaluate_script(expr.to_string(), None, self.client_handle()).await;
if r.is_error != Some(false) {
panic!("eval_bool failed: {}", content_text(&r));
}
let text = content_text(&r);
text.trim() == "true"
}
}
impl Drop for Harness {
fn drop(&mut self) {
if let Some(mut child) = self.chrome.take() {
let _ = child.kill();
let _ = child.wait();
}
}
}
async fn wait_for_debug_port(port: u16, timeout: Duration) -> Result<(), String> {
let start = Instant::now();
let url = format!("http://127.0.0.1:{port}/json/version");
loop {
let u = url.clone();
let ok = tokio::task::spawn_blocking(move || ureq::get(&u).call().is_ok())
.await
.unwrap_or(false);
if ok {
return Ok(());
}
if start.elapsed() >= timeout {
return Err(format!("timed out after {:?}", timeout));
}
tokio::time::sleep(Duration::from_millis(150)).await;
}
}
pub fn content_text(result: &CallToolResult) -> String {
let mut out = String::new();
for c in &result.content {
if let Some(t) = text_of(c) {
if !out.is_empty() {
out.push('\n');
}
out.push_str(&t);
}
}
out
}
fn text_of(content: &Content) -> Option<String> {
content.as_text().map(|t| t.text.clone())
}
pub const HTML_CONTENTEDITABLE: &str = r#"
<!doctype html>
<html><body>
<div id="editor"
contenteditable="true"
data-placeholder="Write something…"
style="min-height:40px;border:1px solid #ccc">
</div>
</body></html>
"#;
pub const HTML_CUSTOM_BUTTON: &str = r#"
<!doctype html>
<html><body>
<div id="x" role="button" aria-label="Close" tabindex="0"
style="width:32px;height:32px;background:#c00"></div>
</body></html>
"#;
pub const HTML_DUPLICATE_LABELS: &str = r#"
<!doctype html>
<html><body>
<nav aria-label="Primary">
<input type="search" placeholder="Search" />
</nav>
<main>
<button aria-label="Search">Search</button>
</main>
</body></html>
"#;
pub const HTML_SHADOW_AND_IFRAME: &str = r#"
<!doctype html>
<html><body>
<host-el id="host"></host-el>
<iframe id="frame" srcdoc='<button aria-label="IframeBtn">IframeBtn</button>'></iframe>
<script>
class HostEl extends HTMLElement {
constructor() {
super();
const root = this.attachShadow({ mode: 'open' });
const b = document.createElement('button');
b.setAttribute('aria-label', 'ShadowBtn');
b.textContent = 'ShadowBtn';
root.appendChild(b);
}
}
customElements.define('host-el', HostEl);
</script>
</body></html>
"#;
pub const HTML_ARIA_VISIBLE_TEXT_MISMATCH: &str = r#"
<!doctype html>
<html><body>
<button aria-label="Chat with Ljuba Isakovic, 0 new messages"
data-testid="conversation-row"
style="width:320px;height:72px;text-align:left">
<span>Note to Self</span>
<span>Tue</span>
<span>Photo</span>
</button>
</body></html>
"#;
pub const HTML_PARENT_TEXT_SHOULD_NOT_MATCH_CHILD: &str = r#"
<!doctype html>
<html><body>
<div role="listitem">
<span>Note to Self</span>
<button aria-label="More actions" style="width:32px;height:32px">...</button>
</div>
</body></html>
"#;