use chromiumoxide::Page;
use crate::accessibility::AXTree;
use crate::cli::WcagLevel;
use crate::wcag::types::{evaluate_or_fail, RuleMetadata, Severity, Violation, WcagResults};
pub const RULE_META: RuleMetadata = RuleMetadata {
id: "4.1.2",
name: "ARIA Relationships",
level: WcagLevel::A,
severity: Severity::Medium,
description: "ARIA relationship attributes must reference valid, non-empty targets",
help_url: "https://www.w3.org/WAI/WCAG21/Understanding/name-role-value.html",
axe_id: "aria-valid-attr",
tags: &["wcag2a", "wcag412", "cat.aria"],
};
const EMPTY_RELATIONSHIP_ATTRS: &[(&str, &str)] = &[
("controls", "aria-controls"),
("owns", "aria-owns"),
("activedescendant", "aria-activedescendant"),
];
pub fn check_aria_relationships(tree: &AXTree) -> WcagResults {
let mut results = WcagResults::new();
for node in tree.iter() {
if node.ignored {
continue;
}
results.nodes_checked += 1;
for (prop_name, attr_name) in EMPTY_RELATIONSHIP_ATTRS {
if !node.has_property(prop_name) {
continue;
}
if node.get_property_idrefs(prop_name).is_empty() {
let violation = Violation::new(
RULE_META.id,
RULE_META.name,
RULE_META.level,
Severity::Medium,
format!("{} references a target but the value is empty", attr_name),
&node.node_id,
)
.with_role(node.role.clone())
.with_fix(format!(
"Either provide a valid ID reference for {} or remove the attribute",
attr_name
))
.with_rule_id(RULE_META.axe_id)
.with_help_url(RULE_META.help_url);
results.add_violation(violation);
} else {
results.passes += 1;
}
}
}
results
}
const ARIA_RELATIONSHIPS_CAP: usize = 250;
const ARIA_RELATIONSHIPS_BODY: &str = r#"
var attrs = ['aria-controls', 'aria-owns', 'aria-activedescendant'];
var selector = attrs.map(function(a) { return '[' + a + ']'; }).join(', ');
var els = document.querySelectorAll(selector);
var issues = [];
for (var i = 0; i < els.length && issues.length < CAP; i++) {
var el = els[i];
for (var a = 0; a < attrs.length; a++) {
var attr = attrs[a];
if (!el.hasAttribute(attr)) continue;
var raw = (el.getAttribute(attr) || '').trim();
var kind = null;
if (!raw) {
kind = 'empty';
} else {
var broken = raw.split(/\s+/).some(function(id) { return !document.getElementById(id); });
if (broken) kind = 'broken';
}
if (kind) {
issues.push({ attr: attr, kind: kind, selector: __amsCssSelector(el) });
}
}
}
return issues;
"#;
pub async fn check_aria_relationships_with_page(page: &Page) -> Vec<Violation> {
let js = [
"(function() {",
crate::accessibility::js_helpers::CSS_SELECTOR_JS,
&ARIA_RELATIONSHIPS_BODY.replace("CAP", &ARIA_RELATIONSHIPS_CAP.to_string()),
"})()",
]
.concat();
let items = match evaluate_or_fail(page, &RULE_META, js.as_str()).await {
Ok(v) => v,
Err(violations) => return violations,
};
let Some(items) = items.as_array() else {
return vec![];
};
items
.iter()
.filter_map(|item| {
let attr = item.get("attr")?.as_str()?;
let kind = item.get("kind")?.as_str()?;
let selector = item.get("selector")?.as_str()?.to_string();
let message = if kind == "empty" {
format!("{} references a target but the value is empty", attr)
} else {
format!(
"{} references a target that does not exist in the page",
attr
)
};
Some(
Violation::new(
RULE_META.id,
RULE_META.name,
RULE_META.level,
RULE_META.severity,
message,
selector.clone(),
)
.with_selector(selector)
.with_fix(format!(
"Either provide a valid ID reference for {} or remove the attribute",
attr
))
.with_rule_id(RULE_META.axe_id)
.with_help_url(RULE_META.help_url),
)
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::accessibility::{AXNode, AXProperty, AXTree, AXValue};
fn make_node(id: &str, role: &str) -> AXNode {
AXNode {
node_id: id.to_string(),
ignored: false,
ignored_reasons: vec![],
role: Some(role.to_string()),
name: Some(format!("Node {}", id)),
name_source: None,
description: None,
value: None,
properties: vec![],
child_ids: vec![],
parent_id: None,
backend_dom_node_id: None,
}
}
fn make_node_with_prop(id: &str, role: &str, prop_name: &str, prop_value: &str) -> AXNode {
let mut node = make_node(id, role);
node.properties.push(AXProperty {
name: prop_name.to_string(),
value: AXValue::String(prop_value.to_string()),
});
node
}
#[test]
fn test_empty_aria_controls_flagged() {
let node = make_node_with_prop("1", "button", "controls", "");
let tree = AXTree::from_nodes(vec![node]);
let results = check_aria_relationships(&tree);
assert!(
results
.violations
.iter()
.any(|v| v.message.contains("aria-controls")),
"Empty aria-controls should be flagged"
);
}
#[test]
fn test_empty_aria_owns_flagged() {
let node = make_node_with_prop("1", "combobox", "owns", "");
let tree = AXTree::from_nodes(vec![node]);
let results = check_aria_relationships(&tree);
assert!(
results
.violations
.iter()
.any(|v| v.message.contains("aria-owns")),
"Empty aria-owns should be flagged"
);
}
#[test]
fn test_empty_aria_activedescendant_flagged() {
let node = make_node_with_prop("1", "listbox", "activedescendant", "");
let tree = AXTree::from_nodes(vec![node]);
let results = check_aria_relationships(&tree);
assert!(
results
.violations
.iter()
.any(|v| v.message.contains("aria-activedescendant")),
"Empty aria-activedescendant should be flagged"
);
}
#[test]
fn test_valid_aria_controls_passes() {
let node = make_node_with_prop("1", "button", "controls", "my-panel");
let tree = AXTree::from_nodes(vec![node]);
let results = check_aria_relationships(&tree);
assert!(
results
.violations
.iter()
.all(|v| !v.message.contains("aria-controls")),
"Valid aria-controls should not be flagged"
);
}
#[test]
fn test_empty_node_relationship_flagged() {
let mut node = make_node("1", "button");
node.properties.push(AXProperty {
name: "controls".to_string(),
value: AXValue::Node {
related_nodes: vec![],
},
});
let tree = AXTree::from_nodes(vec![node]);
let results = check_aria_relationships(&tree);
assert!(
results
.violations
.iter()
.any(|v| v.message.contains("aria-controls")),
"Empty related_nodes (AXValue::Node shape) should be flagged"
);
}
#[test]
fn test_valid_node_relationship_passes() {
let mut node = make_node("1", "button");
node.properties.push(AXProperty {
name: "controls".to_string(),
value: AXValue::Node {
related_nodes: vec![crate::accessibility::RelatedNode {
backend_dom_node_id: None,
idref: Some("panel-1".to_string()),
text: None,
}],
},
});
let tree = AXTree::from_nodes(vec![node]);
let results = check_aria_relationships(&tree);
assert!(
results
.violations
.iter()
.all(|v| !v.message.contains("aria-controls")),
"Non-empty related_nodes should not be flagged"
);
}
}