use a11y_dom::{Node, NodeId};
use a11y_report::{Finding, NotRun, Outcome};
use a11y_rules::Locale;
use crate::accessibility::CdpDocument;
use crate::cli::WcagLevel;
use crate::wcag::types::{RuleRun, Violation, WcagResults};
pub struct SharedRule {
pub id: &'static str,
pub criterion: &'static str,
pub level: WcagLevel,
pub name: &'static str,
pub help_url: &'static str,
}
pub const SHARED_RULES: &[SharedRule] = &[
SharedRule {
id: "document/lang-missing",
criterion: "3.1.1",
level: WcagLevel::A,
name: "Language of Page",
help_url: "https://www.w3.org/WAI/WCAG22/Understanding/language-of-page.html",
},
SharedRule {
id: "document/lang-invalid",
criterion: "3.1.1",
level: WcagLevel::A,
name: "Language of Page",
help_url: "https://www.w3.org/WAI/WCAG22/Understanding/language-of-page.html",
},
SharedRule {
id: "headings/empty",
criterion: "2.4.6",
level: WcagLevel::AA,
name: "Headings and Labels (Empty Heading)",
help_url: "https://www.w3.org/WAI/WCAG22/Understanding/headings-and-labels.html",
},
SharedRule {
id: "headings/skip-level",
criterion: "1.3.1",
level: WcagLevel::A,
name: "Info and Relationships (Heading Hierarchy)",
help_url: "https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html",
},
SharedRule {
id: "headings/h1-missing",
criterion: "1.3.1",
level: WcagLevel::A,
name: "Info and Relationships (Missing Main Heading)",
help_url: "https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html",
},
SharedRule {
id: "headings/h1-multiple",
criterion: "1.3.1",
level: WcagLevel::A,
name: "Info and Relationships (Multiple Main Headings)",
help_url: "https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html",
},
SharedRule {
id: "ids/duplicate",
criterion: "4.1.2",
level: WcagLevel::A,
name: "Name, Role, Value (Referenced Duplicate ID)",
help_url: "https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html",
},
SharedRule {
id: "keyboard/positive-tabindex",
criterion: "2.4.3",
level: WcagLevel::A,
name: "Focus Order",
help_url: "https://www.w3.org/WAI/WCAG22/Understanding/focus-order.html",
},
SharedRule {
id: "lists/invalid-structure",
criterion: "1.3.1",
level: WcagLevel::A,
name: "Info and Relationships (List Structure)",
help_url: "https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html",
},
SharedRule {
id: "lists/empty",
criterion: "1.3.1",
level: WcagLevel::A,
name: "Info and Relationships (Empty List)",
help_url: "https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html",
},
SharedRule {
id: "lists/item-outside-list",
criterion: "1.3.1",
level: WcagLevel::A,
name: "Info and Relationships (Orphan List Item)",
help_url: "https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html",
},
SharedRule {
id: "lists/term-without-definition",
criterion: "1.3.1",
level: WcagLevel::A,
name: "Info and Relationships (Definition Term)",
help_url: "https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html",
},
SharedRule {
id: "tables/header-missing",
criterion: "1.3.1",
level: WcagLevel::A,
name: "Info and Relationships (Table Headers)",
help_url: "https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html",
},
SharedRule {
id: "tables/name-missing",
criterion: "1.3.1",
level: WcagLevel::A,
name: "Info and Relationships (Table Name)",
help_url: "https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html",
},
SharedRule {
id: "tables/presentational-with-headers",
criterion: "1.3.1",
level: WcagLevel::A,
name: "Info and Relationships (Presentational Table)",
help_url: "https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html",
},
SharedRule {
id: "zoom/viewport-scale-limited",
criterion: "1.4.4",
level: WcagLevel::AA,
name: "Resize Text (Viewport Scale)",
help_url: "https://www.w3.org/WAI/WCAG22/Understanding/resize-text.html",
},
SharedRule {
id: "zoom/viewport-locked",
criterion: "1.4.4",
level: WcagLevel::AA,
name: "Resize Text (Viewport Zoom Locked)",
help_url: "https://www.w3.org/WAI/WCAG22/Understanding/resize-text.html",
},
SharedRule {
id: "zoom/viewport-missing",
criterion: "1.4.4",
level: WcagLevel::AA,
name: "Resize Text (Viewport Missing)",
help_url: "https://www.w3.org/WAI/WCAG22/Understanding/resize-text.html",
},
SharedRule {
id: "document/title-missing",
criterion: "2.4.2",
level: WcagLevel::A,
name: "Page Titled",
help_url: "https://www.w3.org/WAI/WCAG22/Understanding/page-titled.html",
},
SharedRule {
id: "document/title-empty",
criterion: "2.4.2",
level: WcagLevel::A,
name: "Page Titled",
help_url: "https://www.w3.org/WAI/WCAG22/Understanding/page-titled.html",
},
SharedRule {
id: "aria/reference-missing",
criterion: "4.1.2",
level: WcagLevel::A,
name: "Name, Role, Value (ARIA Reference)",
help_url: "https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html",
},
SharedRule {
id: "aria/required-attribute-missing",
criterion: "4.1.2",
level: WcagLevel::A,
name: "Name, Role, Value (Required ARIA Attribute)",
help_url: "https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html",
},
SharedRule {
id: "keyboard/hidden-focusable",
criterion: "4.1.2",
level: WcagLevel::A,
name: "Name, Role, Value (Focusable in aria-hidden)",
help_url: "https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html",
},
SharedRule {
id: "landmarks/main-missing",
criterion: "2.4.1",
level: WcagLevel::A,
name: "Bypass Blocks (Main Landmark)",
help_url: "https://www.w3.org/WAI/WCAG22/Understanding/bypass-blocks.html",
},
SharedRule {
id: "landmarks/main-duplicate",
criterion: "1.3.1",
level: WcagLevel::A,
name: "Info and Relationships (Multiple Main Landmarks)",
help_url: "https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html",
},
SharedRule {
id: "landmarks/banner-missing",
criterion: "1.3.1",
level: WcagLevel::A,
name: "Info and Relationships (Banner Landmark)",
help_url: "https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html",
},
SharedRule {
id: "landmarks/contentinfo-missing",
criterion: "1.3.1",
level: WcagLevel::A,
name: "Info and Relationships (Contentinfo Landmark)",
help_url: "https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html",
},
SharedRule {
id: "landmarks/navigation-missing",
criterion: "1.3.1",
level: WcagLevel::A,
name: "Info and Relationships (Navigation Landmark)",
help_url: "https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html",
},
SharedRule {
id: "svg/name-missing",
criterion: "1.1.1",
level: WcagLevel::A,
name: "Non-text Content (SVG)",
help_url: "https://www.w3.org/WAI/WCAG22/Understanding/non-text-content.html",
},
SharedRule {
id: "images/alt-missing",
criterion: "1.1.1",
level: WcagLevel::A,
name: "Non-text Content",
help_url: "https://www.w3.org/WAI/WCAG22/Understanding/non-text-content.html",
},
SharedRule {
id: "images/alt-suspicious",
criterion: "1.1.1",
level: WcagLevel::A,
name: "Non-text Content (Suspicious Alt Text)",
help_url: "https://www.w3.org/WAI/WCAG22/Understanding/non-text-content.html",
},
SharedRule {
id: "links/name-missing",
criterion: "4.1.2",
level: WcagLevel::A,
name: "Name, Role, Value (Link Name)",
help_url: "https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html",
},
SharedRule {
id: "buttons/name-missing",
criterion: "4.1.2",
level: WcagLevel::A,
name: "Name, Role, Value (Button Name)",
help_url: "https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html",
},
SharedRule {
id: "keyboard/skip-link-missing",
criterion: "2.4.1",
level: WcagLevel::A,
name: "Bypass Blocks (Skip Link)",
help_url: "https://www.w3.org/WAI/WCAG22/Understanding/bypass-blocks.html",
},
];
fn shared_rule(id: &str) -> Option<&'static SharedRule> {
SHARED_RULES.iter().find(|r| r.id == id)
}
pub fn svg_ax_node_ids(doc: &CdpDocument) -> std::collections::HashSet<String> {
a11y_dom::elements(doc)
.filter(|n| n.is_element("svg"))
.filter_map(|n| doc.ax_node_id(n).map(str::to_string))
.collect()
}
fn selector_for(node: a11y_dom::ArenaNode<'_>) -> String {
let mut sel = node.local_name().to_string();
if let Some(id) = node.attr("id").filter(|v| !v.trim().is_empty()) {
sel.push('#');
sel.push_str(id.trim());
return sel;
}
if let Some(class) = node.attr("class").filter(|v| !v.trim().is_empty()) {
if let Some(first) = class.split_whitespace().next() {
sel.push('.');
sel.push_str(first);
}
}
sel
}
fn finding_node<'d>(doc: &'d CdpDocument, finding: &Finding) -> Option<a11y_dom::ArenaNode<'d>> {
finding
.location
.node
.as_deref()
.and_then(|s| s.parse::<u32>().ok())
.and_then(|idx| doc.node_at(NodeId(idx)))
}
fn to_violation(doc: &CdpDocument, finding: &Finding, rule: &SharedRule) -> Violation {
let node = finding_node(doc, finding);
let node_id = node
.and_then(|n| doc.ax_node_id(n))
.unwrap_or("document")
.to_string();
let mut violation = Violation::new(
rule.criterion,
rule.name,
rule.level,
finding.severity,
finding.message.clone(),
node_id,
)
.with_rule_id(rule.id)
.with_help_url(rule.help_url)
.with_kind(finding.outcome)
.with_tags(finding.tags.clone());
if let Some(n) = node {
violation = violation.with_selector(selector_for(n));
violation.backend_node_id = doc.backend_node_id(n);
}
if let Some(help) = &finding.help {
violation = violation.with_fix(help.clone());
}
if let Some(code) = &finding.suggested_code {
violation = violation.with_suggested_code(code.clone());
}
if let Some(snippet) = &finding.snippet {
violation = violation.with_html_snippet(snippet.clone());
}
violation.evidence = finding.evidence.clone();
violation
}
fn not_yet_migrated(id: &str) -> RuleRun {
RuleRun::not_run(id, NotRun::Disabled).with_reason("shared_rule_not_yet_adopted")
}
fn locale_for(lang: &str) -> Locale {
if lang == "de" {
Locale::De
} else {
Locale::En
}
}
pub fn run_shared_rules(doc: &CdpDocument, lang: &str) -> WcagResults {
let mut results = WcagResults::new();
let lauf = |locale: Locale| match doc.rendered() {
Some(rendered) => a11y_rules::run_full_in(&rendered, locale),
None => a11y_rules::run_with_semantics_in(doc, locale),
};
let report = lauf(Locale::En);
if locale_for(lang) != Locale::En {
let localized = lauf(locale_for(lang));
if localized.findings.len() == report.findings.len() {
for (en, loc) in report.findings.iter().zip(&localized.findings) {
if en.rule_id != loc.rule_id {
continue;
}
if en.message != loc.message {
results
.localized_texts
.insert(en.message.clone(), loc.message.clone());
}
if let (Some(en_help), Some(loc_help)) = (&en.help, &loc.help) {
if en_help != loc_help {
results
.localized_texts
.insert(en_help.clone(), loc_help.clone());
}
}
}
}
}
for finding in &report.findings {
let Some(rule) = shared_rule(&finding.rule_id) else {
continue;
};
results.add_violation(to_violation(doc, finding, rule));
}
for run in &report.rule_runs {
match shared_rule(&run.rule_id) {
None => results.rule_outcomes.push(not_yet_migrated(&run.rule_id)),
Some(rule) => results
.rule_outcomes
.push(run.clone().with_wcag([rule.criterion])),
}
}
results.passes += report
.findings
.iter()
.filter(|f| f.outcome == Outcome::Pass)
.count();
results
}
#[cfg(test)]
mod tests {
use super::*;
use crate::accessibility::{build_document, AXTree};
use crate::wcag::types::Severity;
use chromiumoxide::cdp::browser_protocol::dom::Node as CdpNode;
fn seite(html_attrs: &[&str]) -> CdpNode {
serde_json::from_value(serde_json::json!({
"nodeId": 1, "backendNodeId": 1, "nodeType": 9,
"nodeName": "#document", "localName": "", "nodeValue": "",
"children": [{
"nodeId": 2, "backendNodeId": 2, "nodeType": 1,
"nodeName": "HTML", "localName": "html", "nodeValue": "",
"attributes": html_attrs,
"children": [{
"nodeId": 3, "backendNodeId": 3, "nodeType": 1,
"nodeName": "BODY", "localName": "body", "nodeValue": "",
"attributes": [], "children": []
}]
}]
}))
.expect("CDP-Knoten")
}
fn ergebnis(html_attrs: &[&str]) -> WcagResults {
let doc = build_document(&seite(html_attrs), &AXTree::new()).unwrap();
run_shared_rules(&doc, "en")
}
#[test]
fn fehlendes_lang_faellt_unter_der_geteilten_kennung() {
let r = ergebnis(&[]);
let ids: Vec<_> = r
.violations
.iter()
.filter_map(|v| v.rule_id.as_deref())
.collect();
assert!(ids.contains(&"document/lang-missing"), "{ids:?}");
}
#[test]
fn deutscher_lauf_legt_deutsche_befundtexte_daneben() {
let doc = build_document(&seite(&[]), &AXTree::new()).unwrap();
let en_text = "The <html> element has no lang attribute.";
let lauf = |lang: &str| run_shared_rules(&doc, lang);
let meldung = |r: &WcagResults| {
r.violations
.iter()
.find(|v| v.rule_id.as_deref() == Some("document/lang-missing"))
.map(|v| v.message.clone())
};
let de = lauf("de");
assert_eq!(meldung(&de).as_deref(), Some(en_text));
assert_eq!(
de.localized_texts.get(en_text).map(String::as_str),
Some("Das <html>-Element hat kein lang-Attribut.")
);
let en = lauf("en");
assert_eq!(meldung(&en).as_deref(), Some(en_text));
assert!(en.localized_texts.is_empty());
}
#[test]
fn geteilter_befund_traegt_kriterium_stufe_und_selektor() {
let r = ergebnis(&[]);
let v = r
.violations
.iter()
.find(|v| v.rule_id.as_deref() == Some("document/lang-missing"))
.expect("Befund");
assert_eq!(v.rule, "3.1.1");
assert_eq!(v.level, WcagLevel::A);
assert_eq!(v.severity, Severity::High);
assert_eq!(v.selector.as_deref(), Some("html"));
assert_eq!(v.kind, Outcome::Fail);
}
#[test]
fn ungueltiger_sprachcode_wird_erkannt() {
let r = ergebnis(&["lang", "x"]);
let ids: Vec<_> = r
.violations
.iter()
.filter_map(|v| v.rule_id.as_deref())
.collect();
assert!(ids.contains(&"document/lang-invalid"), "{ids:?}");
}
#[test]
fn gueltiges_lang_erzeugt_keinen_befund() {
let r = ergebnis(&["lang", "de"]);
assert!(
!r.violations.iter().any(|v| v
.rule_id
.as_deref()
.is_some_and(|id| id.starts_with("document/lang"))),
"unerwartet: {:?}",
r.violations
);
}
#[test]
fn nicht_uebernommene_kennungen_liefern_keine_befunde() {
let r = ergebnis(&["lang", "de"]);
assert!(!r.violations.is_empty());
for v in r.violations.iter().chain(&r.warnings) {
let id = v.rule_id.as_deref().expect("rule_id");
assert!(shared_rule(id).is_some(), "nicht uebernommen: {id}");
}
}
#[test]
fn nicht_uebernommene_kennungen_werden_vermerkt() {
let r = ergebnis(&["lang", "de"]);
let label = r
.rule_outcomes
.iter()
.find(|o| o.rule_id == "forms/label-missing")
.expect("Vermerk zu forms/label-missing");
assert!(crate::wcag::rule_run_skipped(label));
assert_eq!(label.reason.as_deref(), Some("shared_rule_not_yet_adopted"));
}
#[test]
fn jede_kennung_kommt_je_durchgang_genau_einmal_vor() {
let r = ergebnis(&[]);
let mut gesehen = std::collections::BTreeSet::new();
for o in &r.rule_outcomes {
assert!(
gesehen.insert(o.rule_id.clone()),
"Kennung {} doppelt im selben Durchgang",
o.rule_id
);
}
assert_eq!(gesehen.len(), r.rule_outcomes.len());
assert!(gesehen.contains("ids/duplicate"), "{gesehen:?}");
assert!(
gesehen.contains("keyboard/positive-tabindex"),
"{gesehen:?}"
);
}
#[test]
fn svg_kennungen_kommen_aus_dem_ax_baum() {
let kind = |id: i64, tag: &str| {
serde_json::json!({
"nodeId": id, "backendNodeId": id, "nodeType": 1,
"nodeName": tag.to_uppercase(), "localName": tag, "nodeValue": "",
"attributes": [], "children": []
})
};
let dom: CdpNode = serde_json::from_value(serde_json::json!({
"nodeId": 1, "backendNodeId": 1, "nodeType": 9,
"nodeName": "#document", "localName": "", "nodeValue": "",
"children": [{
"nodeId": 2, "backendNodeId": 2, "nodeType": 1,
"nodeName": "HTML", "localName": "html", "nodeValue": "",
"attributes": ["lang", "de"],
"children": [{
"nodeId": 3, "backendNodeId": 3, "nodeType": 1,
"nodeName": "BODY", "localName": "body", "nodeValue": "",
"attributes": [],
"children": [kind(4, "svg"), kind(5, "div")]
}]
}]
}))
.expect("CDP-Knoten");
let ax_knoten = |backend: i64| crate::accessibility::AXNode {
node_id: format!("ax-{backend}"),
ignored: false,
ignored_reasons: vec![],
role: Some("image".to_string()),
name: None,
name_source: None,
description: None,
value: None,
properties: vec![],
child_ids: vec![],
parent_id: None,
backend_dom_node_id: Some(backend),
};
let ax = AXTree::from_nodes(vec![ax_knoten(4), ax_knoten(5)]);
let doc = build_document(&dom, &ax).unwrap();
let ids = svg_ax_node_ids(&doc);
assert_eq!(ids, ["ax-4".to_string()].into());
}
fn seite_mit_doppelter_id(verweis: Option<&str>) -> CdpNode {
let mut kinder = vec![];
if let Some(ziel) = verweis {
kinder.push(serde_json::json!({
"nodeId": 4, "backendNodeId": 4, "nodeType": 1,
"nodeName": "LABEL", "localName": "label", "nodeValue": "",
"attributes": ["for", ziel], "children": []
}));
}
for (i, node_id) in [5, 6].iter().enumerate() {
kinder.push(serde_json::json!({
"nodeId": node_id, "backendNodeId": node_id, "nodeType": 1,
"nodeName": "DIV", "localName": "div", "nodeValue": "",
"attributes": ["id", "dopplung", "data-nr", i.to_string()],
"children": []
}));
}
serde_json::from_value(serde_json::json!({
"nodeId": 1, "backendNodeId": 1, "nodeType": 9,
"nodeName": "#document", "localName": "", "nodeValue": "",
"children": [{
"nodeId": 2, "backendNodeId": 2, "nodeType": 1,
"nodeName": "HTML", "localName": "html", "nodeValue": "",
"attributes": ["lang", "de"],
"children": [{
"nodeId": 3, "backendNodeId": 3, "nodeType": 1,
"nodeName": "BODY", "localName": "body", "nodeValue": "",
"attributes": [], "children": kinder
}]
}]
}))
.expect("CDP-Knoten")
}
fn duplikat_befunde(verweis: Option<&str>) -> Vec<Violation> {
let doc = build_document(&seite_mit_doppelter_id(verweis), &AXTree::new()).unwrap();
run_shared_rules(&doc, "en")
.violations
.into_iter()
.filter(|v| v.rule_id.as_deref() == Some("ids/duplicate"))
.collect()
}
#[test]
fn referenzierte_doppelte_id_faellt_unter_4_1_2() {
let befunde = duplikat_befunde(Some("dopplung"));
assert_eq!(befunde.len(), 1, "{befunde:?}");
assert_eq!(befunde[0].rule, "4.1.2");
}
#[test]
fn unreferenzierte_doppelte_id_ist_kein_befund_mehr() {
assert!(duplikat_befunde(None).is_empty());
assert!(duplikat_befunde(Some("etwas-anderes")).is_empty());
}
#[test]
fn der_vermerk_zaehlt_verworfene_duplikate_nicht_mit() {
let vermerk = |verweis| {
let doc = build_document(&seite_mit_doppelter_id(verweis), &AXTree::new()).unwrap();
run_shared_rules(&doc, "en")
.rule_outcomes
.into_iter()
.find(|o| o.rule_id == "ids/duplicate")
.expect("Vermerk")
.findings
};
assert_eq!(vermerk(None), 0);
assert_eq!(vermerk(Some("dopplung")), 1);
}
#[test]
fn jeder_befund_hat_einen_vermerk_gleicher_kennung() {
let r = ergebnis(&[]);
for v in &r.violations {
let id = v.rule_id.as_deref().expect("rule_id");
assert!(
r.rule_outcomes.iter().any(|o| o.rule_id == id),
"kein Vermerk zu {id}"
);
}
}
}