fix(cdp): improve AXNode CDP spec compliance (#67)

Addresses follow-up issues from PR #44 bot review:

- Add parentId field to AXNode (walk DOM ancestors to resolve AX parent)
- Omit optional fields when empty (name, value, properties, childIds)
  instead of emitting null/empty arrays, per CDP spec
- Add ax_value_boolean() and ax_value_integer() helpers for consistent
  AXValue construction across all property types
- Use typed helpers throughout compute_properties() instead of inline
  JSON construction

CDP spec reference: Accessibility.AXNode requires parentId as AXNodeId,
and optional fields should be absent when not applicable.

Co-authored-by: zhangyang-crazy-one <208990894+zhangyang-crazy-one@users.noreply.github.com>
This commit is contained in:
zhangyang-crazy-oneandzhangyang-crazy-one authored and GitHub committed 2026-05-03 17:09:19 +02:00
1 parent 25b6d58f3e
commit 90ededfc44
1 file changed
+59 -14
+59 -14
View File
@@ -13,6 +13,16 @@ fn ax_value_string(s: &str) -> Value {
json!({"type": "string", "value": s})
}
/// Build a CDP AXValue for a boolean type.
fn ax_value_boolean(b: bool) -> Value {
json!({"type": "boolean", "value": b})
}
/// Build a CDP AXValue for an integer type.
fn ax_value_integer(i: u32) -> Value {
json!({"type": "integer", "value": i})
}
pub async fn handle(
method: &str,
_params: &Value,
@@ -95,16 +105,51 @@ fn build_ax_node(
.filter_map(|child_id| dom_to_ax.get(&child_id.raw()).cloned())
.collect();
Some(json!({
// Resolve parentId — walk DOM ancestors until we find one in the AX tree
let parent_id: Option<String> = {
let mut current = node_id;
let mut result = None;
loop {
let next_parent = dom.with_node(current, |n| n.parent).flatten();
match next_parent {
Some(pid) => {
if let Some(ax_pid) = dom_to_ax.get(&pid.raw()) {
result = Some(ax_pid.clone());
break;
}
current = pid;
}
None => break,
}
}
result
};
// Build node with only non-empty optional fields (per CDP spec, optional fields should be omitted when empty)
let mut ax_node = json!({
"nodeId": ax_id,
"ignored": false,
"role": ax_value_role(role),
"name": name.as_deref().map(ax_value_string),
"value": value.as_deref().map(ax_value_string),
"properties": properties,
"childIds": child_ids,
"backendDOMNodeId": node_id.raw(),
}))
});
if let Some(ref pid) = parent_id {
ax_node.as_object_mut().unwrap().insert("parentId".into(), json!(pid));
}
if let Some(ref n) = name {
ax_node.as_object_mut().unwrap().insert("name".into(), json!(ax_value_string(n)));
}
if let Some(ref v) = value {
ax_node.as_object_mut().unwrap().insert("value".into(), json!(ax_value_string(v)));
}
if !properties.is_empty() {
ax_node.as_object_mut().unwrap().insert("properties".into(), json!(properties));
}
if !child_ids.is_empty() {
ax_node.as_object_mut().unwrap().insert("childIds".into(), json!(child_ids));
}
ax_node.as_object_mut().unwrap().insert("backendDOMNodeId".into(), json!(node_id.raw()));
Some(ax_node)
}
/// Map HTML element tag to ARIA role value.
@@ -333,7 +378,7 @@ fn compute_properties(_dom: &DomTree, node: &obscura_dom::Node) -> Vec<Value> {
an == "tabindex" || an == "contenteditable"
});
if focusable {
props.push(json!({"name": "focusable", "value": {"type": "boolean", "value": true}}));
props.push(json!({"name": "focusable", "value": ax_value_boolean(true)}));
}
// editable
@@ -342,7 +387,7 @@ fn compute_properties(_dom: &DomTree, node: &obscura_dom::Node) -> Vec<Value> {
.iter()
.any(|a| a.name.local.as_ref() == "contenteditable" && a.value != "false")
{
props.push(json!({"name": "editable", "value": {"type": "boolean", "value": true}}));
props.push(json!({"name": "editable", "value": ax_value_boolean(true)}));
}
// checked for checkboxes/radios
@@ -350,7 +395,7 @@ fn compute_properties(_dom: &DomTree, node: &obscura_dom::Node) -> Vec<Value> {
.iter()
.any(|a| a.name.local.as_ref() == "checked")
{
props.push(json!({"name": "checked", "value": {"type": "boolean", "value": true}}));
props.push(json!({"name": "checked", "value": ax_value_boolean(true)}));
}
// disabled
@@ -358,13 +403,13 @@ fn compute_properties(_dom: &DomTree, node: &obscura_dom::Node) -> Vec<Value> {
.iter()
.any(|a| a.name.local.as_ref() == "disabled")
{
props.push(json!({"name": "disabled", "value": {"type": "boolean", "value": true}}));
props.push(json!({"name": "disabled", "value": ax_value_boolean(true)}));
}
// level for headings
if let Some(level) = tag.strip_prefix('h').and_then(|s| s.parse::<u32>().ok()) {
if level >= 1 && level <= 6 {
props.push(json!({"name": "level", "value": {"type": "integer", "value": level}}));
props.push(json!({"name": "level", "value": ax_value_integer(level)}));
}
}
@@ -373,12 +418,12 @@ fn compute_properties(_dom: &DomTree, node: &obscura_dom::Node) -> Vec<Value> {
.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}}));
props.push(json!({"name": "required", "value": ax_value_boolean(true)}));
}
// multiline for textarea
if tag == "textarea" {
props.push(json!({"name": "multiline", "value": {"type": "boolean", "value": true}}));
props.push(json!({"name": "multiline", "value": ax_value_boolean(true)}));
}
props