pub mod dom_discovery;
pub mod tools;
use chromiumoxide::browser::Browser;
use chromiumoxide::page::Page;
use futures_util::StreamExt;
use rmcp::model::{CallToolResult, Content};
use std::collections::HashMap;
use tokio::task::JoinHandle;
pub const DOM_UID_PREFIX: &str = "d";
pub struct CdpClient {
pub browser: Browser,
pub selected_page: Option<Page>,
pub handler_handle: JoinHandle<()>,
pub last_dom_snapshot: Option<SnapshotMap>,
pub last_page_list: Vec<Page>,
pub generation: u64,
}
impl CdpClient {
pub async fn connect(port: u16) -> Result<Self, String> {
let url = format!("http://127.0.0.1:{}", port);
let (mut browser, mut handler) = Browser::connect(&url)
.await
.map_err(|e| format!("Cannot connect to port {}. Is the app running with --remote-debugging-port? Error: {}", port, e))?;
let handler_handle = tokio::spawn(async move { while handler.next().await.is_some() {} });
let selected_page = poll_for_page(&mut browser, std::time::Duration::from_secs(10)).await?;
Ok(Self {
browser,
selected_page,
handler_handle,
last_dom_snapshot: None,
last_page_list: Vec::new(),
generation: 0,
})
}
pub fn disconnect(self) {
self.handler_handle.abort();
}
pub fn invalidate_snapshots(&mut self) {
self.last_dom_snapshot = None;
self.generation = self.generation.wrapping_add(1);
}
pub fn require_page(&self) -> Result<Page, CallToolResult> {
self.selected_page.clone().ok_or_else(|| {
cdp_error("No page selected. Use cdp_list_pages and cdp_select_page first.")
})
}
}
pub async fn page_url(page: &Page) -> String {
page.url().await.ok().flatten().unwrap_or_default()
}
pub(crate) fn is_extension_url(url: &str) -> bool {
url.starts_with("chrome-extension://")
}
async fn first_non_extension_page(pages: &[Page]) -> Option<Page> {
for page in pages {
let url = page_url(page).await;
if !is_extension_url(&url) {
return Some(page.clone());
}
}
None
}
async fn poll_for_page(
browser: &mut Browser,
timeout: std::time::Duration,
) -> Result<Option<Page>, String> {
let _ = browser.fetch_targets().await;
let interval = std::time::Duration::from_millis(100);
let start = std::time::Instant::now();
loop {
let pages = browser
.pages()
.await
.map_err(|e| format!("Failed to list pages: {}", e))?;
if let Some(page) = first_non_extension_page(&pages).await {
return Ok(Some(page));
}
if start.elapsed() >= timeout {
return Ok(None);
}
tokio::time::sleep(interval).await;
}
}
pub fn cdp_error(msg: impl Into<String>) -> CallToolResult {
CallToolResult::error(vec![Content::text(msg.into())])
}
pub struct SnapshotMap {
pub uid_to_node: HashMap<String, SnapshotNode>,
pub uid_to_candidate: HashMap<String, dom_discovery::DomCandidate>,
pub backend_to_uids: HashMap<i64, Vec<String>>,
pub ordered_uids: Vec<String>,
pub page_url: String,
pub generation: u64,
}
pub struct SnapshotNode {
pub backend_node_id: i64,
pub role: String,
pub name: String,
}
pub fn resolve_uid_from_maps<'a>(
uid: &str,
dom_snapshot: Option<&'a SnapshotMap>,
current_generation: u64,
current_url: &str,
) -> Result<&'a SnapshotNode, String> {
if !uid.starts_with(DOM_UID_PREFIX) {
return Err(format!(
"Unknown UID prefix in '{}'. Expected 'd<N>' (DOM).",
uid
));
}
let snapshot = dom_snapshot.ok_or(
"No DOM snapshot available. Call cdp_take_dom_snapshot or cdp_find_elements first.",
)?;
if current_generation != snapshot.generation || current_url != snapshot.page_url {
return Err(
"Snapshot is stale — page has navigated since last snapshot. \
Call cdp_take_dom_snapshot or cdp_find_elements again."
.to_string(),
);
}
snapshot.uid_to_node.get(uid).ok_or_else(|| {
format!(
"uid={} not found in DOM snapshot. Take a fresh snapshot.",
uid
)
})
}
#[cfg(test)]
mod tests {
use super::*;
const URL: &str = "https://example.com/";
fn make_dom_map(generation: u64, uid: &str, backend_node_id: i64) -> SnapshotMap {
let mut map = SnapshotMap {
uid_to_node: HashMap::new(),
uid_to_candidate: HashMap::new(),
backend_to_uids: HashMap::new(),
ordered_uids: vec![uid.to_string()],
page_url: URL.to_string(),
generation,
};
map.uid_to_node.insert(
uid.to_string(),
SnapshotNode {
backend_node_id,
role: "button".to_string(),
name: "Submit".to_string(),
},
);
map
}
#[test]
fn resolve_uid_dom_prefix() {
let dom_map = make_dom_map(3, "d5", 99);
let result = resolve_uid_from_maps("d5", Some(&dom_map), 3, URL);
assert!(result.is_ok());
let node = result.unwrap();
assert_eq!(node.backend_node_id, 99);
}
#[test]
fn resolve_uid_unknown_prefix_fails() {
for uid in ["x1", "a1"] {
match resolve_uid_from_maps(uid, None, 0, URL) {
Err(msg) => assert!(
msg.contains("Unknown UID prefix"),
"uid={} got: {}",
uid,
msg
),
Ok(_) => panic!("expected unknown-prefix error for uid={}", uid),
}
}
}
fn expect_stale(result: Result<&SnapshotNode, String>) {
match result {
Err(msg) => assert!(msg.contains("stale"), "expected stale error, got: {}", msg),
Ok(_) => panic!("expected stale-snapshot error, got Ok"),
}
}
#[test]
fn resolve_uid_stale_generation_fails() {
let dom_map = make_dom_map(1, "d1", 1);
expect_stale(resolve_uid_from_maps("d1", Some(&dom_map), 2, URL));
}
#[test]
fn same_url_reload_invalidates_snapshot() {
let dom_map = make_dom_map(0, "d1", 42);
expect_stale(resolve_uid_from_maps("d1", Some(&dom_map), 1, URL));
}
#[test]
fn out_of_band_url_change_invalidates_snapshot() {
let dom_map = make_dom_map(0, "d1", 42);
expect_stale(resolve_uid_from_maps(
"d1",
Some(&dom_map),
0,
"https://example.com/different",
));
}
#[test]
fn snapshot_taken_before_navigation_is_stale_after_bump() {
let dom_map = make_dom_map(0, "d1", 42);
assert!(resolve_uid_from_maps("d1", Some(&dom_map), 0, URL).is_ok());
expect_stale(resolve_uid_from_maps("d1", Some(&dom_map), 1, URL));
}
}