From a4591707e35f5f71da523fce1d95da05fb08413e Mon Sep 17 00:00:00 2001 From: zhangyang-crazy-one <208990894+zhangyang-crazy-one@users.noreply.github.com> Date: Mon, 27 Apr 2026 12:02:38 +0800 Subject: [PATCH] feat(cdp): implement Accessibility domain (getFullAXTree) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the CDP Accessibility domain with getFullAXTree support, enabling AI agent tools (agent-browser, Playwright MCP) that rely on Accessibility.getFullAXTree to work with Obscura. Changes: - Add crates/obscura-cdp/src/domains/accessibility.rs (~380 lines) - Accessibility.enable: returns {} - Accessibility.getFullAXTree: walks DOM tree, produces AXNode array - Role mapping: 50+ HTML tags → ARIA roles, respects explicit role attr - Name computation: aria-label → aria-labelledby → alt → title → placeholder - Properties: focusable, editable, checked, disabled, level, required, multiline - Update mod.rs: register accessibility module - Update dispatch.rs: route Accessibility domain to real handler Tested with example.com (18 nodes), httpbin.org/html (16 nodes), news.ycombinator.com (1308 nodes). Zero structure errors. Closes #12 --- crates/obscura-cdp/src/dispatch.rs | 3 +- .../obscura-cdp/src/domains/accessibility.rs | 378 ++++++++++++++++++ crates/obscura-cdp/src/domains/mod.rs | 1 + 3 files changed, 381 insertions(+), 1 deletion(-) create mode 100644 crates/obscura-cdp/src/domains/accessibility.rs diff --git a/crates/obscura-cdp/src/dispatch.rs b/crates/obscura-cdp/src/dispatch.rs index 47f52fc..26480bd 100644 --- a/crates/obscura-cdp/src/dispatch.rs +++ b/crates/obscura-cdp/src/dispatch.rs @@ -135,8 +135,9 @@ pub async fn dispatch(req: &CdpRequest, ctx: &mut CdpContext) -> CdpResponse { "Input" => domains::input::handle(method, &req.params, ctx, &req.session_id).await, "Storage" => domains::storage::handle(method, &req.params, ctx, &req.session_id).await, "LP" => domains::lp::handle(method, &req.params, ctx, &req.session_id).await, + "Accessibility" => domains::accessibility::handle(method, &req.params, ctx, &req.session_id).await, "Emulation" | "Log" | "Performance" | "Security" | "CSS" - | "Accessibility" | "ServiceWorker" | "Inspector" + | "ServiceWorker" | "Inspector" | "Debugger" | "Profiler" | "HeapProfiler" | "Overlay" => { Ok(json!({})) } diff --git a/crates/obscura-cdp/src/domains/accessibility.rs b/crates/obscura-cdp/src/domains/accessibility.rs new file mode 100644 index 0000000..2e130c0 --- /dev/null +++ b/crates/obscura-cdp/src/domains/accessibility.rs @@ -0,0 +1,378 @@ +use obscura_dom::{DomTree, NodeData, NodeId}; +use serde_json::{json, Value}; + +use crate::dispatch::CdpContext; + +pub async fn handle( + method: &str, + _params: &Value, + ctx: &mut CdpContext, + session_id: &Option, +) -> Result { + match method { + "enable" => Ok(json!({})), + "getFullAXTree" => { + let page = ctx + .get_session_page(session_id) + .ok_or("No page")?; + let nodes = page + .with_dom(|dom| build_ax_nodes(dom)) + .unwrap_or_default(); + Ok(json!({ "nodes": nodes })) + } + _ => Ok(json!({})), + } +} + +/// Walk the full DOM tree and produce CDP Accessibility AXNode array. +fn build_ax_nodes(dom: &DomTree) -> Vec { + let mut nodes: Vec = Vec::new(); + let mut id_counter: u32 = 0; + // Map DOM NodeId → AX string id, populated only for nodes actually in the AX tree + let mut dom_to_ax: std::collections::HashMap = std::collections::HashMap::new(); + + let document = dom.document(); + + // Collect all DOM nodes in tree order (root + descendants) + let mut all_dom_ids: Vec = vec![document]; + all_dom_ids.extend(dom.descendants(document)); + + // First pass: assign AX IDs only to nodes that will produce an AX node + let mut eligible: Vec = Vec::new(); + for dom_id in &all_dom_ids { + // Quick check without full build_ax_node (just role check to avoid borrow issues) + if let Some(node) = dom.get_node(*dom_id) { + let role = map_role(&node.data); + if !role.is_empty() { + id_counter += 1; + dom_to_ax.insert(dom_id.raw(), id_counter.to_string()); + eligible.push(*dom_id); + } + } + } + + // Second pass: build AXNode for eligible nodes + for dom_id in &eligible { + if let Some(ax) = build_ax_node(dom, *dom_id, &dom_to_ax) { + nodes.push(ax); + } + } + + nodes +} + +fn build_ax_node( + dom: &DomTree, + node_id: NodeId, + dom_to_ax: &std::collections::HashMap, +) -> Option { + let node = dom.get_node(node_id)?; + let ax_id = dom_to_ax.get(&node_id.raw())?.clone(); + + let role = map_role(&node.data); + // Skip non-relevant nodes (Document, Doctype, Comment, PI) + if role.is_empty() { + return None; + } + + let name = compute_name(dom, &node); + let value = compute_value(dom, &node); + let properties = compute_properties(dom, &node); + + let child_ids: Vec = dom + .children(node_id) + .iter() + .filter_map(|child_id| dom_to_ax.get(&child_id.raw()).cloned()) + .collect(); + + Some(json!({ + "nodeId": ax_id, + "ignored": false, + "role": role, + "name": name, + "value": value, + "properties": properties, + "childIds": child_ids, + "backendDOMNodeId": node_id.raw(), + })) +} + +/// Map HTML element tag to ARIA role value. +fn map_role(data: &NodeData) -> &'static str { + match data { + NodeData::Document => "RootWebArea", + NodeData::Element { name, attrs, .. } => { + let tag = name.local.as_ref(); + + // Check explicit role attribute first + if let Some(role_attr) = attrs.iter().find(|a| a.name.local.as_ref() == "role") { + return match role_attr.value.as_str() { + "button" => "button", + "link" => "link", + "heading" => "heading", + "textbox" | "searchbox" => "textbox", + "checkbox" => "checkbox", + "radio" => "radio", + "listbox" => "listbox", + "combobox" => "combobox", + "list" => "list", + "listitem" => "listitem", + "navigation" => "navigation", + "banner" => "banner", + "main" => "main", + "complementary" => "complementary", + "contentinfo" => "contentinfo", + "form" => "form", + "table" => "table", + "row" => "row", + "cell" | "gridcell" => "cell", + "img" => "image", + "dialog" => "dialog", + "alert" => "alert", + "tab" => "tab", + "tablist" => "tablist", + "tabpanel" => "tabpanel", + "menu" => "menu", + "menuitem" => "menuitem", + "toolbar" => "toolbar", + "separator" => "separator", + "presentation" | "none" => { + // presentation/none roles get the role but content is still in tree + "presentation" + } + _ => "generic", + }; + } + + match tag { + "a" => { + if attrs.iter().any(|a| a.name.local.as_ref() == "href") { + "link" + } else { + "generic" + } + } + "button" | "summary" => "button", + "input" => { + let type_attr = attrs + .iter() + .find(|a| a.name.local.as_ref() == "type") + .map(|a| a.value.as_str()) + .unwrap_or("text"); + match type_attr { + "submit" | "reset" | "button" | "image" => "button", + "checkbox" => "checkbox", + "radio" => "radio", + "range" => "slider", + "number" => "spinbutton", + "search" => "searchbox", + _ => "textbox", + } + } + "textarea" => "textbox", + "select" => { + if attrs.iter().any(|a| { + a.name.local.as_ref() == "multiple" + || a.name.local.as_ref() == "size" + }) { + "listbox" + } else { + "combobox" + } + } + "h1" | "h2" | "h3" | "h4" | "h5" | "h6" => "heading", + "img" | "svg" => "image", + "ul" | "ol" | "menu" => "list", + "li" => "listitem", + "table" => "table", + "tr" => "row", + "td" | "th" => "cell", + "nav" => "navigation", + "header" => "banner", + "main" => "main", + "footer" => "contentinfo", + "form" => "form", + "dialog" => "dialog", + "hr" => "separator", + "label" => "LabelText", + "article" => "article", + "aside" => "complementary", + "section" => "region", + "figure" => "figure", + "figcaption" => "StaticText", + "p" | "div" | "span" | "pre" | "blockquote" | "code" + | "em" | "strong" | "b" | "i" | "u" | "s" | "small" + | "sub" | "sup" | "mark" | "del" | "ins" => "generic", + "iframe" => "Iframe", + _ => "generic", + } + } + NodeData::Text { .. } => "StaticText", + NodeData::Doctype { .. } | NodeData::Comment { .. } | NodeData::ProcessingInstruction { .. } => { + "" + } + } +} + +/// Compute the accessible name for a node. +fn compute_name(dom: &DomTree, node: &obscura_dom::Node) -> Option { + if let NodeData::Element { attrs, .. } = &node.data { + // aria-label takes highest priority + if let Some(label) = attrs + .iter() + .find(|a| a.name.local.as_ref() == "aria-label") + .map(|a| a.value.clone()) + { + return Some(json!(label)); + } + + // aria-labelledby + if let Some(labelledby) = attrs + .iter() + .find(|a| a.name.local.as_ref() == "aria-labelledby") + { + let ids: Vec<&str> = labelledby.value.split_whitespace().collect(); + let mut name = String::new(); + for id_str in ids { + if let Some(ref_id) = dom.get_element_by_id(id_str) { + name.push_str(&dom.text_content(ref_id)); + name.push(' '); + } + } + let trimmed = name.trim().to_string(); + if !trimmed.is_empty() { + return Some(json!(trimmed)); + } + } + + // alt attribute for images + if let Some(alt) = attrs + .iter() + .find(|a| a.name.local.as_ref() == "alt") + .map(|a| a.value.clone()) + { + if !alt.is_empty() { + return Some(json!(alt)); + } + } + + // title attribute + if let Some(title) = attrs + .iter() + .find(|a| a.name.local.as_ref() == "title") + .map(|a| a.value.clone()) + { + if !title.is_empty() { + return Some(json!(title)); + } + } + + // placeholder + if let Some(placeholder) = attrs + .iter() + .find(|a| a.name.local.as_ref() == "placeholder") + .map(|a| a.value.clone()) + { + if !placeholder.is_empty() { + return Some(json!(placeholder)); + } + } + } + + // For text nodes, the name is the text content + if let NodeData::Text { contents } = &node.data { + let trimmed = contents.trim().to_string(); + if !trimmed.is_empty() { + return Some(json!(trimmed)); + } + } + + None +} + +/// Compute the accessible value for a node (e.g., current input value). +fn compute_value(_dom: &DomTree, node: &obscura_dom::Node) -> Option { + if let NodeData::Element { name, attrs, .. } = &node.data { + let tag = name.local.as_ref(); + // For input elements, return the value attribute + if tag == "input" || tag == "textarea" || tag == "select" { + if let Some(val) = attrs + .iter() + .find(|a| a.name.local.as_ref() == "value") + .map(|a| json!(a.value.clone())) + { + return Some(val); + } + } + } + None +} + +/// Compute accessibility properties for a node. +fn compute_properties(_dom: &DomTree, node: &obscura_dom::Node) -> Vec { + if let NodeData::Element { name, attrs, .. } = &node.data { + let tag = name.local.as_ref(); + let mut props = Vec::new(); + + // focusable + let focusable = matches!( + tag, + "a" | "button" | "input" | "select" | "textarea" | "details" | "summary" + ) || attrs.iter().any(|a| { + let an = a.name.local.as_ref(); + an == "tabindex" || an == "contenteditable" + }); + if focusable { + props.push(json!({"name": "focusable", "value": {"type": "boolean", "value": true}})); + } + + // editable + if tag == "input" || tag == "textarea" + || attrs + .iter() + .any(|a| a.name.local.as_ref() == "contenteditable" && a.value != "false") + { + props.push(json!({"name": "editable", "value": {"type": "boolean", "value": true}})); + } + + // checked for checkboxes/radios + if attrs + .iter() + .any(|a| a.name.local.as_ref() == "checked") + { + props.push(json!({"name": "checked", "value": {"type": "boolean", "value": true}})); + } + + // disabled + if attrs + .iter() + .any(|a| a.name.local.as_ref() == "disabled") + { + props.push(json!({"name": "disabled", "value": {"type": "boolean", "value": true}})); + } + + // level for headings + if let Some(level) = tag.strip_prefix('h').and_then(|s| s.parse::().ok()) { + if level >= 1 && level <= 6 { + props.push(json!({"name": "level", "value": {"type": "integer", "value": level}})); + } + } + + // required + if attrs + .iter() + .any(|a| a.name.local.as_ref() == "required" || a.name.local.as_ref() == "aria-required") + { + props.push(json!({"name": "required", "value": {"type": "boolean", "value": true}})); + } + + // multiline for textarea + if tag == "textarea" { + props.push(json!({"name": "multiline", "value": {"type": "boolean", "value": true}})); + } + + props + } else { + Vec::new() + } +} diff --git a/crates/obscura-cdp/src/domains/mod.rs b/crates/obscura-cdp/src/domains/mod.rs index 4ed300d..a99a319 100644 --- a/crates/obscura-cdp/src/domains/mod.rs +++ b/crates/obscura-cdp/src/domains/mod.rs @@ -7,4 +7,5 @@ pub mod network; pub mod fetch; pub mod input; pub mod storage; +pub mod accessibility; pub mod lp;