auditmysite 1.7.2

Resource-efficient WCAG 2.2 AA Accessibility Checker in Rust
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
//! WCAG 4.1.2 - ARIA Role Validity
//!
//! Validates that ARIA roles are valid and that required owned elements are
//! present. Required-parent-context validation lives in
//! `aria_required_parent.rs` to avoid double-reporting the same violation.
//!
//! Invalid-role detection (`check_invalid_role_with_page`) is a DOM-level
//! rule: Chrome repairs an invalid explicit `role="..."` attribute before
//! exposing the element in the AX tree (falling back to the native/implicit
//! role or `generic`), so a tree-based check can never observe the
//! author's mistake — an earlier implementation only ever false-positived on
//! Chrome-internal AX roles missing from its allowlist (#QA-030).

use chromiumoxide::Page;

use crate::accessibility::AXTree;
use crate::cli::WcagLevel;
use crate::wcag::types::{RuleMetadata, Severity, Violation, WcagResults};

/// Rule metadata for ARIA role validity
pub(super) const RULE_META: RuleMetadata = RuleMetadata {
    id: "4.1.2",
    name: "ARIA Role Validity",
    level: WcagLevel::A,
    severity: Severity::High,
    description: "ARIA roles must be valid and required owned elements must be present",
    help_url: "https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html",
    axe_id: "aria-roles",
    tags: &["wcag2a", "wcag412", "cat.aria"],
};

/// Roles that require specific child roles to be present
/// (role, required_child_roles)
const REQUIRED_OWNED_ELEMENTS: &[(&str, &[&str])] = &[
    ("list", &["listitem"]),
    ("table", &["row", "rowgroup"]),
    ("grid", &["row", "rowgroup"]),
    ("treegrid", &["row", "rowgroup"]),
    ("menu", &["menuitem", "menuitemcheckbox", "menuitemradio"]),
    (
        "menubar",
        &["menuitem", "menuitemcheckbox", "menuitemradio"],
    ),
    ("tree", &["treeitem"]),
    ("listbox", &["option"]),
    ("radiogroup", &["radio"]),
    ("tablist", &["tab"]),
];

/// Check ARIA role validity across the accessibility tree
pub fn check_aria_roles(tree: &AXTree) -> WcagResults {
    let mut results = WcagResults::new();

    for node in tree.iter() {
        if node.ignored {
            continue;
        }
        results.nodes_checked += 1;

        let role = match node.role.as_deref() {
            Some(r) => r,
            None => continue,
        };

        // Invalid-role detection now runs as a DOM page rule
        // (check_invalid_role_with_page) — Chrome repairs invalid roles
        // before AX extraction, so this tree-based check could only ever
        // false-positive on Chrome-internal roles (#QA-030).

        // Invalid-aria-attribute-name detection now runs as a DOM page rule
        // (check_invalid_aria_attribute_name_with_page) — CDP never exposes
        // aria-*-prefixed property names, so this was dead code (#QA-030).

        // Check for missing required owned elements
        check_required_owned_elements(node, role, tree, &mut results);

        // Required-parent-context is checked exclusively by
        // aria_required_parent.rs — this file used to duplicate it via
        // `check_required_context`, causing every violation to be reported
        // twice (#QA-033).
    }

    results
}

const INVALID_ROLE_CAP: usize = 250;

/// Real ARIA 1.2 role list (abstract roles excluded — authors must not use
/// them). Deliberately does NOT include Chrome-internal AX roles
/// (WebArea, LayoutTable, StaticText, …) since those never appear as an
/// author-set `role="..."` attribute value in the DOM.
const VALID_DOM_ARIA_ROLES_JS: &str = r#"
  var validRoles = [
    'alert','alertdialog','application','article','banner','blockquote','button',
    'caption','cell','checkbox','code','columnheader','combobox','complementary',
    'contentinfo','definition','deletion','dialog','directory','document','emphasis',
    'feed','figure','form','generic','grid','gridcell','group','heading','img',
    'insertion','link','list','listbox','listitem','log','main','mark','marquee',
    'math','menu','menubar','menuitem','menuitemcheckbox','menuitemradio','meter',
    'navigation','none','note','option','paragraph','presentation','progressbar',
    'radio','radiogroup','region','row','rowgroup','rowheader','scrollbar','search',
    'searchbox','separator','slider','spinbutton','status','strong','subscript',
    'superscript','switch','tab','table','tablist','tabpanel','term','textbox',
    'time','timer','toolbar','tooltip','tree','treegrid','treeitem'
  ];
"#;

const INVALID_ROLE_BODY: &str = r#"
  var issues = [];
  var elems = document.querySelectorAll('[role]');
  for (var i = 0; i < elems.length && __amsReal(issues) < CAP; i++) {
    var el = elems[i];
    var roles = (el.getAttribute('role') || '').trim().toLowerCase().split(/\s+/).filter(Boolean);
    for (var r = 0; r < roles.length; r++) {
      if (validRoles.indexOf(roles[r]) === -1) {
        __amsPush(issues, el, { role: roles[r], selector: __amsCssSelector(el) }, CAP);
        break;
      }
    }
  }
  return { issues: issues };
"#;

/// Check that explicit `role="..."` attribute values are valid ARIA roles.
pub async fn check_invalid_role_with_page(page: &Page) -> Vec<Violation> {
    let js = [
        "(function() {",
        crate::accessibility::js_helpers::CSS_SELECTOR_JS,
        VALID_DOM_ARIA_ROLES_JS,
        &INVALID_ROLE_BODY.replace("CAP", &INVALID_ROLE_CAP.to_string()),
        "})()",
    ]
    .concat();

    let val = match crate::wcag::types::evaluate_or_fail_for(
        page,
        "invalid-role",
        crate::cli::WcagLevel::A,
        js.as_str(),
    )
    .await
    {
        Ok(v) => v,
        Err(violations) => return violations,
    };

    let issues = match val.get("issues").and_then(|v| v.as_array()) {
        Some(arr) => arr.clone(),
        None => return vec![],
    };

    issues
        .iter()
        .filter_map(|issue| {
            let role = issue.get("role")?.as_str()?;
            let selector = issue.get("selector")?.as_str()?.to_string();

            Some(
                Violation::new(
                    RULE_META.id,
                    RULE_META.name,
                    RULE_META.level,
                    Severity::High,
                    format!("Element has invalid ARIA role: '{}'", role),
                    selector.clone(),
                )
                .with_selector(selector)
                .with_fix("Use a valid ARIA role from the ARIA specification")
                .with_rule_id(RULE_META.axe_id)
                .with_help_url("https://www.w3.org/TR/wai-aria-1.2/#role_definitions"),
            )
        })
        .collect()
}

/// Distinct from `RULE_META.axe_id` ("aria-roles", owned by `check_invalid_role`) —
/// this check validates ARIA attribute *names* (misspelled/non-existent
/// aria-* attributes), a different problem from an invalid role value. Not a
/// real axe-core rule id (axe folds this into `aria-valid-attr`, which this
/// codebase used for an unrelated check until #690 — see #QA-009 remaining
/// scope), so this is a custom, stable identifier.
const INVALID_ARIA_ATTR_NAME_AXE_ID: &str = "aria-attr-name-invalid";

const INVALID_ATTR_NAME_CAP: usize = 250;

const VALID_ARIA_ATTRIBUTES_JS: &str = r#"
  var validAttrs = [
    'aria-activedescendant', 'aria-atomic', 'aria-autocomplete', 'aria-braillelabel',
    'aria-brailleroledescription', 'aria-busy', 'aria-checked', 'aria-colcount',
    'aria-colindex', 'aria-colindextext', 'aria-colspan', 'aria-controls', 'aria-current',
    'aria-describedby', 'aria-description', 'aria-details', 'aria-disabled',
    'aria-dropeffect', 'aria-errormessage', 'aria-expanded', 'aria-flowto', 'aria-grabbed',
    'aria-haspopup', 'aria-hidden', 'aria-invalid', 'aria-keyshortcuts', 'aria-label',
    'aria-labelledby', 'aria-level', 'aria-live', 'aria-modal', 'aria-multiline',
    'aria-multiselectable', 'aria-orientation', 'aria-owns', 'aria-placeholder',
    'aria-posinset', 'aria-pressed', 'aria-readonly', 'aria-relevant', 'aria-required',
    'aria-roledescription', 'aria-rowcount', 'aria-rowindex', 'aria-rowindextext',
    'aria-rowspan', 'aria-selected', 'aria-setsize', 'aria-sort', 'aria-valuemax',
    'aria-valuemin', 'aria-valuenow', 'aria-valuetext'
  ];
"#;

const INVALID_ATTR_NAME_BODY: &str = r#"
  var issues = [];
  var elems = document.querySelectorAll('*');
  for (var i = 0; i < elems.length && __amsReal(issues) < CAP; i++) {
    var el = elems[i];
    var attrs = el.attributes;
    for (var a = 0; a < attrs.length; a++) {
      var name = attrs[a].name;
      if (name.indexOf('aria-') !== 0) continue;
      if (validAttrs.indexOf(name) === -1) {
        __amsPush(issues, el, { attr: name, selector: __amsCssSelector(el) }, CAP);
        if (__amsReal(issues) >= CAP) break;
      }
    }
  }
  return { issues: issues };
"#;

/// Check that `aria-*` attribute names are recognized ARIA 1.2 attributes
/// (catches misspellings/non-existent attributes). DOM-level: CDP never
/// exposes `aria-`-prefixed AX property names, so a tree-based check here
/// was dead code (#QA-030).
pub async fn check_invalid_aria_attribute_name_with_page(page: &Page) -> Vec<Violation> {
    let js = [
        "(function() {",
        crate::accessibility::js_helpers::CSS_SELECTOR_JS,
        VALID_ARIA_ATTRIBUTES_JS,
        &INVALID_ATTR_NAME_BODY.replace("CAP", &INVALID_ATTR_NAME_CAP.to_string()),
        "})()",
    ]
    .concat();

    let val = match crate::wcag::types::evaluate_or_fail_for(
        page,
        "invalid-aria-attribute-name",
        crate::cli::WcagLevel::A,
        js.as_str(),
    )
    .await
    {
        Ok(v) => v,
        Err(violations) => return violations,
    };

    let issues = match val.get("issues").and_then(|v| v.as_array()) {
        Some(arr) => arr.clone(),
        None => return vec![],
    };

    issues
        .iter()
        .filter_map(|issue| {
            let attr = issue.get("attr")?.as_str()?;
            let selector = issue.get("selector")?.as_str()?.to_string();

            Some(
                Violation::new(
                    RULE_META.id,
                    RULE_META.name,
                    RULE_META.level,
                    Severity::High,
                    format!("Element has invalid ARIA attribute: '{}'", attr),
                    selector.clone(),
                )
                .with_selector(selector)
                .with_fix("Use a valid aria-* attribute from the ARIA specification")
                .with_rule_id(INVALID_ARIA_ATTR_NAME_AXE_ID)
                .with_help_url("https://www.w3.org/TR/wai-aria-1.2/#state_prop_def"),
            )
        })
        .collect()
}

fn check_required_owned_elements(
    node: &crate::accessibility::AXNode,
    role: &str,
    tree: &AXTree,
    results: &mut WcagResults,
) {
    let required = REQUIRED_OWNED_ELEMENTS.iter().find(|(r, _)| *r == role);

    let (_, required_child_roles) = match required {
        Some(r) => r,
        None => return,
    };

    // Collapsed containers (aria-expanded="false") legitimately have no
    // visible children in the AX tree — don't flag them.
    let is_collapsed = node
        .get_property_str("expanded")
        .map(|v| v == "false")
        .unwrap_or(false)
        || node.get_property_bool("expanded") == Some(false);
    if is_collapsed {
        return;
    }

    let has_required_child = has_owned_role(tree, &node.child_ids, required_child_roles, 0);

    if !has_required_child && !node.child_ids.is_empty() {
        let violation = Violation::new(
            RULE_META.id,
            "ARIA Required Owned Elements",
            RULE_META.level,
            Severity::High,
            format!(
                "Element with role '{}' is missing required child {}: {}",
                role,
                if required_child_roles.len() == 1 {
                    "role"
                } else {
                    "roles"
                },
                required_child_roles.join(", ")
            ),
            &node.node_id,
        )
        .with_role(node.role.clone())
        .with_fix(format!(
            "Add child elements with {}: {}",
            if required_child_roles.len() == 1 {
                "role"
            } else {
                "roles"
            },
            required_child_roles.join(", ")
        ))
        .with_help_url("https://www.w3.org/TR/wai-aria-1.2/#mustContain")
        .with_rule_id(RULE_META.axe_id);

        results.add_violation(violation);
    }
}

const MAX_OWNED_DEPTH: usize = 4;

/// Whether any owned child carries one of `roles`. An ignored child is not an
/// owned element itself, but Chrome marks a plain `<tbody>` ignored while its
/// rows stay exposed beneath it (#638, #659), so look through it.
fn has_owned_role(tree: &AXTree, child_ids: &[String], roles: &[&str], depth: usize) -> bool {
    child_ids.iter().any(|child_id| {
        let Some(child) = tree.nodes.get(child_id) else {
            return false;
        };
        let matches = child
            .role
            .as_deref()
            .is_some_and(|child_role| roles.contains(&child_role));
        matches
            || (child.ignored
                && depth < MAX_OWNED_DEPTH
                && has_owned_role(tree, &child.child_ids, roles, depth + 1))
    })
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::accessibility::{AXNode, AXProperty, AXTree, AXValue};

    fn make_node(id: &str, role: &str, parent_id: Option<&str>, child_ids: Vec<&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: child_ids.into_iter().map(String::from).collect(),
            parent_id: parent_id.map(String::from),
            backend_dom_node_id: None,
        }
    }

    #[test]
    fn test_valid_role_passes() {
        let nodes = vec![
            make_node("1", "WebArea", None, vec!["2"]),
            make_node("2", "button", Some("1"), vec![]),
        ];
        let tree = AXTree::from_nodes(nodes);
        let results = check_aria_roles(&tree);
        assert_eq!(results.violations.len(), 0);
    }

    // Invalid-role detection moved to check_invalid_role_with_page (DOM-based,
    // #QA-030) — not unit-tested here since it needs a live Page.

    #[test]
    fn test_list_without_listitem_flagged() {
        let nodes = vec![
            make_node("1", "WebArea", None, vec!["2"]),
            make_node("2", "list", Some("1"), vec!["3"]),
            // child has wrong role
            make_node("3", "paragraph", Some("2"), vec![]),
        ];
        let tree = AXTree::from_nodes(nodes);
        let results = check_aria_roles(&tree);
        assert!(results
            .violations
            .iter()
            .any(|v| v.message.contains("listitem")));
    }

    #[test]
    fn test_table_rows_under_ignored_tbody_pass() {
        // #659: Chrome marks a plain <tbody> ignored; its rows stay exposed.
        let mut tbody = make_node("3", "none", Some("2"), vec!["4"]);
        tbody.ignored = true;
        let nodes = vec![
            make_node("1", "WebArea", None, vec!["2"]),
            make_node("2", "table", Some("1"), vec!["3"]),
            tbody,
            make_node("4", "row", Some("3"), vec!["5"]),
            make_node("5", "cell", Some("4"), vec![]),
        ];
        let results = check_aria_roles(&AXTree::from_nodes(nodes));
        assert!(
            !results
                .violations
                .iter()
                .any(|v| v.message.contains("missing required child")),
            "rows beneath an ignored tbody must count: {:?}",
            results.violations
        );
    }

    #[test]
    fn test_table_without_rows_still_flagged() {
        // A table holding only a caption (e.g. still waiting for its data)
        // has no row to own and stays reported.
        let mut tbody = make_node("3", "none", Some("2"), vec![]);
        tbody.ignored = true;
        let nodes = vec![
            make_node("1", "WebArea", None, vec!["2"]),
            make_node("2", "table", Some("1"), vec!["4", "3"]),
            make_node("4", "caption", Some("2"), vec![]),
            tbody,
        ];
        let results = check_aria_roles(&AXTree::from_nodes(nodes));
        assert!(results.violations.iter().any(|v| v
            .message
            .contains("missing required child roles: row, rowgroup")));
    }

    #[test]
    fn test_collapsed_menu_not_flagged() {
        let mut menu = make_node("2", "menu", Some("1"), vec![]);
        menu.properties.push(AXProperty {
            name: "expanded".to_string(),
            value: AXValue::Bool(false),
        });
        let nodes = vec![make_node("1", "WebArea", None, vec!["2"]), menu];
        let tree = AXTree::from_nodes(nodes);
        let results = check_aria_roles(&tree);
        assert!(
            !results
                .violations
                .iter()
                .any(|v| v.message.contains("missing required child")),
            "Collapsed menu should not be flagged for missing children"
        );
    }

    // Invalid-ARIA-attribute-name detection moved to
    // check_invalid_aria_attribute_name_with_page (DOM-based, #QA-030) —
    // needs a live Page, not unit-tested here.
}