use std::collections::HashMap; use serde_json::Value; use super::cdp::client::CdpClient; use super::cdp::types::*; #[derive(Debug, Clone)] pub struct RefEntry { pub backend_node_id: Option, pub role: String, pub name: String, pub nth: Option, pub selector: Option, } pub struct RefMap { map: HashMap, next_ref: usize, } impl RefMap { pub fn new() -> Self { Self { map: HashMap::new(), next_ref: 1, } } pub fn add( &mut self, ref_id: String, backend_node_id: Option, role: &str, name: &str, nth: Option, ) { self.map.insert( ref_id, RefEntry { backend_node_id, role: role.to_string(), name: name.to_string(), nth, selector: None, }, ); } pub fn get(&self, ref_id: &str) -> Option<&RefEntry> { self.map.get(ref_id) } pub fn clear(&mut self) { self.map.clear(); self.next_ref = 1; } pub fn next_ref_num(&self) -> usize { self.next_ref } pub fn set_next_ref_num(&mut self, n: usize) { self.next_ref = n; } } pub fn parse_ref(input: &str) -> Option { let trimmed = input.trim(); if let Some(stripped) = trimmed.strip_prefix('@') { if stripped.starts_with('e') && stripped[1..].chars().all(|c| c.is_ascii_digit()) { return Some(stripped.to_string()); } } if let Some(stripped) = trimmed.strip_prefix("ref=") { if stripped.starts_with('e') && stripped[1..].chars().all(|c| c.is_ascii_digit()) { return Some(stripped.to_string()); } } if trimmed.starts_with('e') && trimmed.len() > 1 && trimmed[1..].chars().all(|c| c.is_ascii_digit()) { return Some(trimmed.to_string()); } None } pub async fn resolve_element_center( client: &CdpClient, session_id: &str, ref_map: &RefMap, selector_or_ref: &str, ) -> Result<(f64, f64), String> { if let Some(ref_id) = parse_ref(selector_or_ref) { let entry = ref_map .get(&ref_id) .ok_or_else(|| format!("Unknown ref: {}", ref_id))?; if let Some(backend_node_id) = entry.backend_node_id { let result: DomGetBoxModelResult = client .send_command_typed( "DOM.getBoxModel", &DomGetBoxModelParams { backend_node_id: Some(backend_node_id), node_id: None, object_id: None, }, Some(session_id), ) .await?; return Ok(box_model_center(&result.model)); } // Fallback: use role/name to find via JS return resolve_by_role_name(client, session_id, &entry.role, &entry.name, entry.nth).await; } // CSS selector resolve_by_selector(client, session_id, selector_or_ref).await } pub async fn resolve_element_object_id( client: &CdpClient, session_id: &str, ref_map: &RefMap, selector_or_ref: &str, ) -> Result { if let Some(ref_id) = parse_ref(selector_or_ref) { let entry = ref_map .get(&ref_id) .ok_or_else(|| format!("Unknown ref: {}", ref_id))?; if let Some(backend_node_id) = entry.backend_node_id { let result: DomResolveNodeResult = client .send_command_typed( "DOM.resolveNode", &DomResolveNodeParams { backend_node_id: Some(backend_node_id), node_id: None, object_group: Some("agent-browser".to_string()), }, Some(session_id), ) .await?; return result .object .object_id .ok_or_else(|| format!("No objectId for ref {}", ref_id)); } } // CSS selector fallback let js = format!( "document.querySelector({})", serde_json::to_string(selector_or_ref).unwrap_or_default() ); let result: EvaluateResult = client .send_command_typed( "Runtime.evaluate", &EvaluateParams { expression: js, return_by_value: Some(false), await_promise: Some(false), }, Some(session_id), ) .await?; result .result .object_id .ok_or_else(|| format!("Element not found: {}", selector_or_ref)) } async fn resolve_by_role_name( client: &CdpClient, session_id: &str, role: &str, name: &str, nth: Option, ) -> Result<(f64, f64), String> { let nth_index = nth.unwrap_or(0); let js = format!( r#"(() => {{ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT); const matches = []; let node; while (node = walker.nextNode()) {{ const r = node.getAttribute('role') || node.tagName.toLowerCase(); const n = node.getAttribute('aria-label') || node.textContent.trim().slice(0, 100); if (r === {role} && n === {name}) matches.push(node); }} const el = matches[{nth}]; if (!el) return null; const rect = el.getBoundingClientRect(); return {{ x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }}; }})()"#, role = serde_json::to_string(role).unwrap_or_default(), name = serde_json::to_string(name).unwrap_or_default(), nth = nth_index, ); let result: EvaluateResult = client .send_command_typed( "Runtime.evaluate", &EvaluateParams { expression: js, return_by_value: Some(true), await_promise: Some(false), }, Some(session_id), ) .await?; let val = result.result.value.unwrap_or(Value::Null); let x = val.get("x").and_then(|v| v.as_f64()); let y = val.get("y").and_then(|v| v.as_f64()); match (x, y) { (Some(x), Some(y)) => Ok((x, y)), _ => Err(format!( "Could not locate element with role={} name={}", role, name )), } } async fn resolve_by_selector( client: &CdpClient, session_id: &str, selector: &str, ) -> Result<(f64, f64), String> { let js = format!( r#"(() => {{ const el = document.querySelector({sel}); if (!el) return null; const rect = el.getBoundingClientRect(); return {{ x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }}; }})()"#, sel = serde_json::to_string(selector).unwrap_or_default(), ); let result: EvaluateResult = client .send_command_typed( "Runtime.evaluate", &EvaluateParams { expression: js, return_by_value: Some(true), await_promise: Some(false), }, Some(session_id), ) .await?; let val = result.result.value.unwrap_or(Value::Null); let x = val.get("x").and_then(|v| v.as_f64()); let y = val.get("y").and_then(|v| v.as_f64()); match (x, y) { (Some(x), Some(y)) => Ok((x, y)), _ => Err(format!("Element not found: {}", selector)), } } fn box_model_center(model: &BoxModel) -> (f64, f64) { // content quad: [x1,y1, x2,y2, x3,y3, x4,y4] if model.content.len() >= 8 { let x = (model.content[0] + model.content[2] + model.content[4] + model.content[6]) / 4.0; let y = (model.content[1] + model.content[3] + model.content[5] + model.content[7]) / 4.0; (x, y) } else { (0.0, 0.0) } } pub async fn get_element_text( client: &CdpClient, session_id: &str, ref_map: &RefMap, selector_or_ref: &str, ) -> Result { let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?; let result: EvaluateResult = client .send_command_typed( "Runtime.callFunctionOn", &CallFunctionOnParams { function_declaration: "function() { return this.innerText || this.textContent || ''; }".to_string(), object_id: Some(object_id), arguments: None, return_by_value: Some(true), await_promise: Some(false), }, Some(session_id), ) .await?; Ok(result .result .value .and_then(|v| v.as_str().map(|s| s.to_string())) .unwrap_or_default()) } pub async fn get_element_attribute( client: &CdpClient, session_id: &str, ref_map: &RefMap, selector_or_ref: &str, attribute: &str, ) -> Result { let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?; let result: EvaluateResult = client .send_command_typed( "Runtime.callFunctionOn", &CallFunctionOnParams { function_declaration: format!( "function() {{ return this.getAttribute({}); }}", serde_json::to_string(attribute).unwrap_or_default() ), object_id: Some(object_id), arguments: None, return_by_value: Some(true), await_promise: Some(false), }, Some(session_id), ) .await?; Ok(result.result.value.unwrap_or(Value::Null)) } pub async fn is_element_visible( client: &CdpClient, session_id: &str, ref_map: &RefMap, selector_or_ref: &str, ) -> Result { let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?; let result: EvaluateResult = client .send_command_typed( "Runtime.callFunctionOn", &CallFunctionOnParams { function_declaration: r#"function() { const rect = this.getBoundingClientRect(); const style = window.getComputedStyle(this); return rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none' && parseFloat(style.opacity) > 0; }"# .to_string(), object_id: Some(object_id), arguments: None, return_by_value: Some(true), await_promise: Some(false), }, Some(session_id), ) .await?; Ok(result .result .value .and_then(|v| v.as_bool()) .unwrap_or(false)) } pub async fn is_element_enabled( client: &CdpClient, session_id: &str, ref_map: &RefMap, selector_or_ref: &str, ) -> Result { let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?; let result: EvaluateResult = client .send_command_typed( "Runtime.callFunctionOn", &CallFunctionOnParams { function_declaration: "function() { return !this.disabled; }".to_string(), object_id: Some(object_id), arguments: None, return_by_value: Some(true), await_promise: Some(false), }, Some(session_id), ) .await?; Ok(result .result .value .and_then(|v| v.as_bool()) .unwrap_or(true)) } pub async fn is_element_checked( client: &CdpClient, session_id: &str, ref_map: &RefMap, selector_or_ref: &str, ) -> Result { let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?; let result: EvaluateResult = client .send_command_typed( "Runtime.callFunctionOn", &CallFunctionOnParams { function_declaration: "function() { return !!this.checked; }".to_string(), object_id: Some(object_id), arguments: None, return_by_value: Some(true), await_promise: Some(false), }, Some(session_id), ) .await?; Ok(result .result .value .and_then(|v| v.as_bool()) .unwrap_or(false)) } pub async fn get_element_inner_text( client: &CdpClient, session_id: &str, ref_map: &RefMap, selector_or_ref: &str, ) -> Result { let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?; let result: EvaluateResult = client .send_command_typed( "Runtime.callFunctionOn", &CallFunctionOnParams { function_declaration: "function() { return this.innerText || ''; }".to_string(), object_id: Some(object_id), arguments: None, return_by_value: Some(true), await_promise: Some(false), }, Some(session_id), ) .await?; Ok(result .result .value .and_then(|v| v.as_str().map(|s| s.to_string())) .unwrap_or_default()) } pub async fn get_element_inner_html( client: &CdpClient, session_id: &str, ref_map: &RefMap, selector_or_ref: &str, ) -> Result { let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?; let result: EvaluateResult = client .send_command_typed( "Runtime.callFunctionOn", &CallFunctionOnParams { function_declaration: "function() { return this.innerHTML || ''; }".to_string(), object_id: Some(object_id), arguments: None, return_by_value: Some(true), await_promise: Some(false), }, Some(session_id), ) .await?; Ok(result .result .value .and_then(|v| v.as_str().map(|s| s.to_string())) .unwrap_or_default()) } pub async fn get_element_input_value( client: &CdpClient, session_id: &str, ref_map: &RefMap, selector_or_ref: &str, ) -> Result { let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?; let result: EvaluateResult = client .send_command_typed( "Runtime.callFunctionOn", &CallFunctionOnParams { function_declaration: "function() { return typeof this.value === 'string' ? this.value : ''; }" .to_string(), object_id: Some(object_id), arguments: None, return_by_value: Some(true), await_promise: Some(false), }, Some(session_id), ) .await?; Ok(result .result .value .and_then(|v| v.as_str().map(|s| s.to_string())) .unwrap_or_default()) } pub async fn set_element_value( client: &CdpClient, session_id: &str, ref_map: &RefMap, selector_or_ref: &str, value: &str, ) -> Result<(), String> { let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?; let js = format!( "function() {{ this.value = {}; this.dispatchEvent(new Event('input', {{bubbles: true}})); this.dispatchEvent(new Event('change', {{bubbles: true}})); }}", serde_json::to_string(value).unwrap_or_default() ); client .send_command_typed::<_, EvaluateResult>( "Runtime.callFunctionOn", &CallFunctionOnParams { function_declaration: js, object_id: Some(object_id), arguments: None, return_by_value: Some(true), await_promise: Some(false), }, Some(session_id), ) .await?; Ok(()) } pub async fn get_element_bounding_box( client: &CdpClient, session_id: &str, ref_map: &RefMap, selector_or_ref: &str, ) -> Result { let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?; let result: EvaluateResult = client .send_command_typed( "Runtime.callFunctionOn", &CallFunctionOnParams { function_declaration: r#"function() { const r = this.getBoundingClientRect(); return { x: r.x, y: r.y, width: r.width, height: r.height }; }"# .to_string(), object_id: Some(object_id), arguments: None, return_by_value: Some(true), await_promise: Some(false), }, Some(session_id), ) .await?; result .result .value .ok_or_else(|| format!("Could not get bounding box for: {}", selector_or_ref)) } pub async fn get_element_count( client: &CdpClient, session_id: &str, selector: &str, ) -> Result { let js = format!( "document.querySelectorAll({}).length", serde_json::to_string(selector).unwrap_or_default() ); let result: EvaluateResult = client .send_command_typed( "Runtime.evaluate", &EvaluateParams { expression: js, return_by_value: Some(true), await_promise: Some(false), }, Some(session_id), ) .await?; Ok(result.result.value.and_then(|v| v.as_i64()).unwrap_or(0)) } pub async fn get_element_styles( client: &CdpClient, session_id: &str, ref_map: &RefMap, selector_or_ref: &str, properties: Option>, ) -> Result { let object_id = resolve_element_object_id(client, session_id, ref_map, selector_or_ref).await?; let js = match properties { Some(props) => { let props_json = serde_json::to_string(&props).unwrap_or("[]".to_string()); format!( r#"function() {{ const s = window.getComputedStyle(this); const props = {}; const result = {{}}; for (const p of props) result[p] = s.getPropertyValue(p); return result; }}"#, props_json ) } None => r#"function() { const s = window.getComputedStyle(this); const result = {}; for (let i = 0; i < s.length; i++) { const p = s[i]; result[p] = s.getPropertyValue(p); } return result; }"# .to_string(), }; let result: EvaluateResult = client .send_command_typed( "Runtime.callFunctionOn", &CallFunctionOnParams { function_declaration: js, object_id: Some(object_id), arguments: None, return_by_value: Some(true), await_promise: Some(false), }, Some(session_id), ) .await?; Ok(result.result.value.unwrap_or(Value::Null)) } #[cfg(test)] mod tests { use super::*; #[test] fn test_parse_ref_at_prefix() { assert_eq!(parse_ref("@e1"), Some("e1".to_string())); assert_eq!(parse_ref("@e123"), Some("e123".to_string())); } #[test] fn test_parse_ref_equals_prefix() { assert_eq!(parse_ref("ref=e1"), Some("e1".to_string())); } #[test] fn test_parse_ref_bare() { assert_eq!(parse_ref("e1"), Some("e1".to_string())); assert_eq!(parse_ref("e42"), Some("e42".to_string())); } #[test] fn test_parse_ref_invalid() { assert_eq!(parse_ref("button"), None); assert_eq!(parse_ref("e"), None); assert_eq!(parse_ref("1"), None); assert_eq!(parse_ref(""), None); } #[test] fn test_ref_map_basic() { let mut map = RefMap::new(); map.add("e1".to_string(), Some(42), "button", "Submit", None); assert!(map.get("e1").is_some()); assert_eq!(map.get("e1").unwrap().role, "button"); assert!(map.get("e2").is_none()); } #[test] fn test_box_model_center() { let model = BoxModel { content: vec![10.0, 20.0, 110.0, 20.0, 110.0, 60.0, 10.0, 60.0], padding: vec![], border: vec![], margin: vec![], width: 100, height: 40, }; let (x, y) = box_model_center(&model); assert!((x - 60.0).abs() < 0.01); assert!((y - 40.0).abs() < 0.01); } }