use obscura_browser::Page; use obscura_dom::{DomTree, NodeData, NodeId}; use serde_json::{json, Value}; use crate::dispatch::CdpContext; /// Resolve a DOM `nodeId` from CDP params. Honors `nodeId`, `backendNodeId`, /// and `objectId` in that order. Playwright commonly passes only `objectId` /// (returned by a prior `DOM.resolveNode`); without this fallback those /// requests silently default to node 0 and click the wrong element. fn resolve_node_id(page: &mut Page, params: &Value) -> Result { if let Some(nid) = params.get("nodeId").and_then(|v| v.as_u64()) { return Ok(nid); } if let Some(nid) = params.get("backendNodeId").and_then(|v| v.as_u64()) { return Ok(nid); } if let Some(oid) = params.get("objectId").and_then(|v| v.as_str()) { let code = format!( "(function() {{ var o = globalThis.__obscura_objects && globalThis.__obscura_objects['{}']; \ return (o && typeof o._nid === 'number') ? o._nid : -1; }})()", oid.replace('\'', "\\'") ); let result = page.evaluate(&code); let nid = result.as_f64().map(|n| n as i64).unwrap_or(-1); if nid < 0 { return Err(format!("objectId {oid} could not be resolved to a node")); } return Ok(nid as u64); } Err("nodeId, backendNodeId, or objectId required".to_string()) } pub async fn handle( method: &str, params: &Value, ctx: &mut CdpContext, session_id: &Option, ) -> Result { match method { "enable" => Ok(json!({})), "getDocument" => { let page = ctx.get_session_page(session_id).ok_or("No page")?; let depth = params.get("depth").and_then(|v| v.as_i64()).unwrap_or(2); page.with_dom(|dom| { let node = serialize_node(dom, dom.document(), depth as u32, 0); json!({ "root": node }) }).ok_or_else(|| "No DOM loaded".to_string()) } "querySelector" => { let page = ctx.get_session_page(session_id).ok_or("No page")?; let selector = params.get("selector").and_then(|v| v.as_str()).ok_or("selector required")?; let result = page.with_dom(|dom| { dom.query_selector(selector).ok().flatten().map(|id| id.index()).unwrap_or(0) }).unwrap_or(0); Ok(json!({ "nodeId": result })) } "querySelectorAll" => { let page = ctx.get_session_page(session_id).ok_or("No page")?; let selector = params.get("selector").and_then(|v| v.as_str()).ok_or("selector required")?; let ids = page.with_dom(|dom| { dom.query_selector_all(selector).ok() .map(|ids| ids.iter().map(|id| id.index() as u64).collect::>()) .unwrap_or_default() }).unwrap_or_default(); Ok(json!({ "nodeIds": ids })) } "getOuterHTML" => { let page = ctx.get_session_page(session_id).ok_or("No page")?; let node_id = params.get("nodeId").and_then(|v| v.as_u64()) .or_else(|| params.get("backendNodeId").and_then(|v| v.as_u64())) .ok_or("nodeId required")?; let html = page.with_dom(|dom| { dom.outer_html(NodeId::new(node_id as u32)) }).unwrap_or_default(); Ok(json!({ "outerHTML": html })) } "describeNode" => { let page = ctx.get_session_page_mut(session_id).ok_or("No page")?; let depth = params.get("depth").and_then(|v| v.as_i64()).unwrap_or(0); let node_id = if let Some(nid) = params.get("nodeId").and_then(|v| v.as_u64()) .or_else(|| params.get("backendNodeId").and_then(|v| v.as_u64())) { nid } else if let Some(oid) = params.get("objectId").and_then(|v| v.as_str()) { let escaped_oid = oid.replace('\\', "\\\\").replace('\'', "\\'"); let code = format!( "(function() {{ var o = globalThis.__obscura_objects['{}']; if (!o) return -1; return (typeof o._nid === 'number') ? o._nid : -1; }})()", escaped_oid ); let result = page.evaluate(&code); result.as_f64().map(|n| n as u64).unwrap_or(0) } else { return Err("nodeId or objectId required".to_string()); }; let node = page.with_dom(|dom| { serialize_node(dom, NodeId::new(node_id as u32), depth as u32, 0) }).unwrap_or(json!(null)); Ok(json!({ "node": node })) } "resolveNode" => { let page = ctx.get_session_page_mut(session_id).ok_or("No page")?; let node_id = if let Some(nid) = params.get("nodeId").and_then(|v| v.as_u64()) .or_else(|| params.get("backendNodeId").and_then(|v| v.as_u64())) { nid } else if let Some(oid) = params.get("objectId").and_then(|v| v.as_str()) { let code = format!( "(function() {{ var o = globalThis.__obscura_objects['{}']; return (o && typeof o._nid === 'number') ? o._nid : -1; }})()", oid ); let result = page.evaluate(&code); result.as_f64().map(|n| n as u64).unwrap_or(0) } else { return Err("nodeId or objectId required".to_string()); }; let js_code = format!( "(function() {{\ var nid = {};\ var node = null;\ if (globalThis._cache && globalThis._cache.has(nid)) {{\ node = globalThis._cache.get(nid);\ }} else {{\ var t = +Deno.core.ops.op_dom('node_type', String(nid), '');\ if (t === 1) node = new Element(nid);\ else if (t === 9) node = globalThis.document;\ else node = new Node(nid);\ if (globalThis._cache) globalThis._cache.set(nid, node);\ }}\ return node;\ }})()", node_id, ); let info = if let Some(js) = &mut page.js { match js.store_object_with_meta(&js_code) { Ok(info) => info, Err(_) => { return Ok(json!({ "object": { "type": "object", "subtype": "node", "className": "HTMLElement", "objectId": format!("node-{}", node_id), } })); } } } else { return Err("No JS runtime".to_string()); }; Ok(json!({ "object": { "type": "object", "subtype": "node", "className": if info.class_name.is_empty() { "HTMLElement".to_string() } else { info.class_name }, "description": info.description, "objectId": info.object_id.unwrap_or_else(|| format!("node-{}", node_id)), } })) } "setAttributeValue" => Ok(json!({})), "removeNode" => Ok(json!({})), "focus" => { // No layout engine, but obscura's JS focus() sets document.activeElement, // which Input.dispatchKeyEvent targets. CDP clients (browser-use) focus an // input via DOM.focus before typing; without this their keystrokes land on // nothing and the field stays empty. let page = ctx.get_session_page_mut(session_id).ok_or("No page")?; let node_id = resolve_node_id(page, params)?; let code = format!( "(function() {{ var el = globalThis._wrap && globalThis._wrap({0}); \ if (el && typeof el.focus === 'function') {{ el.focus(); return true; }} return false; }})()", node_id ); let _ = page.evaluate(&code); Ok(json!({})) } "getBoxModel" => { let page = ctx.get_session_page_mut(session_id).ok_or("No page")?; let node_id = match resolve_node_id(page, params) { Ok(nid) => nid, Err(_) => return Ok(json!(null)), }; let code = format!( "(function() {{\ var el = globalThis._wrap && globalThis._wrap({0});\ if (!el || typeof el.getBoundingClientRect !== 'function') return null;\ var r = el.getBoundingClientRect();\ return [r.left, r.top, r.right, r.top, r.right, r.bottom, r.left, r.bottom,\ r.width, r.height];\ }})()", node_id ); let val = page.evaluate(&code); let (quad, w, h) = if let Some(arr) = val.as_array() { let nums: Vec = arr.iter().filter_map(|v| v.as_f64()).collect(); if nums.len() >= 10 { let q: Vec = nums[..8].iter().map(|n| json!(n)).collect(); (q, nums[8], nums[9]) } else { (vec![json!(8),json!(8),json!(108),json!(8),json!(108),json!(28),json!(8),json!(28)], 100.0, 20.0) } } else { (vec![json!(8),json!(8),json!(108),json!(8),json!(108),json!(28),json!(8),json!(28)], 100.0, 20.0) }; Ok(json!({ "model": { "content": quad.clone(), "padding": quad.clone(), "border": quad.clone(), "margin": quad, "width": w, "height": h, } })) } "getContentQuads" => { let page = ctx.get_session_page_mut(session_id).ok_or("No page")?; let node_id = match resolve_node_id(page, params) { Ok(nid) => nid, Err(_) => return Ok(json!(null)), }; let code = format!( "(function() {{\ var el = globalThis._wrap && globalThis._wrap({0});\ if (!el || typeof el.getBoundingClientRect !== 'function') return null;\ var r = el.getBoundingClientRect();\ return [r.left, r.top, r.right, r.top, r.right, r.bottom, r.left, r.bottom];\ }})()", node_id ); let val = page.evaluate(&code); let quad = if let Some(arr) = val.as_array() { let nums: Vec = arr.iter().filter_map(|v| v.as_f64()).collect(); if nums.len() == 8 { nums.iter().map(|n| json!(n)).collect::>() } else { vec![json!(8),json!(8),json!(108),json!(8),json!(108),json!(28),json!(8),json!(28)] } } else { vec![json!(8),json!(8),json!(108),json!(8),json!(108),json!(28),json!(8),json!(28)] }; Ok(json!({ "quads": [quad] })) } _ => Err(format!("Unknown DOM method: {}", method)), } } fn serialize_node(dom: &DomTree, node_id: NodeId, max_depth: u32, current_depth: u32) -> Value { let node = match dom.get_node(node_id) { Some(n) => n, None => return json!(null), }; let children_ids = dom.children(node_id); let child_count = children_ids.len(); let mut result = json!({ "nodeId": node_id.index(), "backendNodeId": node_id.index(), "childNodeCount": child_count }); match &node.data { NodeData::Document => { result["nodeType"] = json!(9); result["nodeName"] = json!("#document"); result["localName"] = json!(""); result["nodeValue"] = json!(""); result["documentURL"] = json!(""); result["baseURL"] = json!(""); result["xmlVersion"] = json!(""); } NodeData::Doctype { name, public_id, system_id } => { result["nodeType"] = json!(10); result["nodeName"] = json!(name); result["localName"] = json!(""); result["nodeValue"] = json!(""); result["publicId"] = json!(public_id); result["systemId"] = json!(system_id); } NodeData::Element { name, attrs, .. } => { result["nodeType"] = json!(1); result["nodeName"] = json!(name.local.as_ref().to_ascii_uppercase()); result["localName"] = json!(name.local.as_ref()); result["nodeValue"] = json!(""); let cdp_attrs: Vec = attrs.iter() .flat_map(|a| vec![a.name.local.to_string(), a.value.clone()]).collect(); result["attributes"] = json!(cdp_attrs); } NodeData::Text { contents } => { result["nodeType"] = json!(3); result["nodeName"] = json!("#text"); result["localName"] = json!(""); result["nodeValue"] = json!(contents); } NodeData::Comment { contents } => { result["nodeType"] = json!(8); result["nodeName"] = json!("#comment"); result["localName"] = json!(""); result["nodeValue"] = json!(contents); } NodeData::ProcessingInstruction { target, data } => { result["nodeType"] = json!(7); result["nodeName"] = json!(target); result["localName"] = json!(""); result["nodeValue"] = json!(data); } } if current_depth < max_depth && !children_ids.is_empty() { let children: Vec = children_ids.iter() .map(|&cid| serialize_node(dom, cid, max_depth, current_depth + 1)).collect(); result["children"] = json!(children); } result } #[cfg(test)] mod tests { use super::*; use crate::dispatch::CdpContext; #[tokio::test] async fn dom_focus_sets_active_element() { // CDP clients (browser-use) focus an input via DOM.focus before typing; // dispatchKeyEvent then targets document.activeElement. DOM.focus must // actually move focus or keystrokes land on nothing. let mut ctx = CdpContext::new(); let page_id = ctx.create_page(); let session = Some(format!("{page_id}-session")); ctx.sessions.insert(session.clone().unwrap(), page_id.clone()); crate::domains::page::handle( "navigate", &json!({ "url": "data:text/html,", "waitUntil": "load" }), &mut ctx, &session, ) .await .expect("navigate should succeed"); let qs = handle("querySelector", &json!({ "selector": "input" }), &mut ctx, &session) .await .expect("querySelector should succeed"); let nid = qs["nodeId"].as_u64().expect("input nodeId"); assert!(nid > 0, "the input element should be found"); handle("focus", &json!({ "nodeId": nid }), &mut ctx, &session) .await .expect("DOM.focus should succeed"); let active = ctx .get_session_page_mut(&session) .unwrap() .evaluate("(function(){return document.activeElement?document.activeElement.tagName:'NONE';})()"); assert_eq!( active, json!("INPUT"), "DOM.focus must set document.activeElement to the focused input" ); } }