use std::collections::{HashMap, HashSet};
use a11y_dom::{
Arena, ArenaNode, ComputedStyle, Document, NameSource as SharedNameSource, Node, Rect,
Rendering, Semantics,
};
use chromiumoxide::cdp::browser_protocol::dom::{GetDocumentParams, Node as CdpNode};
use chromiumoxide::cdp::browser_protocol::dom_snapshot::{
CaptureSnapshotParams, CaptureSnapshotReturns,
};
use chromiumoxide::Page;
use tracing::{debug, warn};
use super::extractor::ChromeNameSources;
use crate::error::{AuditError, Result};
use a11y_perception::{AXTree, NameSource};
const ELEMENT_NODE: i64 = 1;
const TEXT_NODE: i64 = 3;
#[derive(Debug, Clone, Default)]
struct AxFacts {
ax_node_id: String,
role: Option<String>,
name: Option<String>,
name_source: Option<SharedNameSource>,
chrome_name_source: Option<String>,
ignored: bool,
}
pub struct CdpDocument {
arena: Arena,
backend_ids: Vec<Option<i64>>,
ax: HashMap<i64, AxFacts>,
layout: Option<LayoutStyles>,
}
impl CdpDocument {
pub fn backend_node_id(&self, node: ArenaNode<'_>) -> Option<i64> {
self.backend_ids
.get(node.id().0 as usize)
.copied()
.flatten()
}
pub fn len(&self) -> usize {
self.arena.len()
}
pub fn is_empty(&self) -> bool {
self.arena.is_empty()
}
pub fn node_at(&self, id: a11y_dom::NodeId) -> Option<ArenaNode<'_>> {
self.arena.get(id)
}
pub fn ax_node_id(&self, node: ArenaNode<'_>) -> Option<&str> {
Some(self.facts(node)?.ax_node_id.as_str())
}
pub fn with_chrome_name_sources(mut self, sources: &ChromeNameSources) -> Self {
for (backend, facts) in &mut self.ax {
if let Some(label) = sources.get(backend) {
facts.name_source = shared_name_source(label);
facts.chrome_name_source = Some(label.clone());
}
}
self
}
pub fn chrome_name_source(&self, node: ArenaNode<'_>) -> Option<&str> {
self.facts(node)?.chrome_name_source.as_deref()
}
pub fn rendered(&self) -> Option<RenderedCdpDocument<'_>> {
Some(RenderedCdpDocument {
doc: self,
styles: self.layout.as_ref()?,
})
}
fn facts(&self, node: ArenaNode<'_>) -> Option<&AxFacts> {
self.ax.get(&self.backend_node_id(node)?)
}
}
#[derive(Debug, Clone, Default)]
pub struct LayoutStyles {
by_backend: HashMap<i64, LayoutStyle>,
whitespace_before: HashSet<i64>,
whitespace_last: HashSet<i64>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct LayoutStyle {
display: String,
visibility: Option<String>,
}
impl LayoutStyles {
pub fn len(&self) -> usize {
self.by_backend.len()
}
pub fn is_empty(&self) -> bool {
self.by_backend.is_empty()
}
pub fn from_snapshot(snapshot: &CaptureSnapshotReturns) -> Self {
let string = |i: i64| {
usize::try_from(i)
.ok()
.and_then(|i| snapshot.strings.get(i))
};
let mut by_backend = HashMap::new();
let mut whitespace_before = HashSet::new();
let mut whitespace_last = HashSet::new();
for document in &snapshot.documents {
let nodes = &document.nodes;
let (Some(backend), Some(types), Some(parents)) = (
nodes.backend_node_id.as_ref(),
nodes.node_type.as_ref(),
nodes.parent_index.as_ref(),
) else {
continue;
};
let mut styles: Vec<Option<LayoutStyle>> = vec![None; backend.len()];
let mut renders_below = vec![false; backend.len()];
for (layout_index, &node_index) in document.layout.node_index.iter().enumerate() {
let Ok(node_index) = usize::try_from(node_index) else {
continue;
};
if let Some(values) = document.layout.styles.get(layout_index) {
let values = values.inner();
styles[node_index] =
values
.first()
.and_then(|d| string(*d.inner()))
.map(|display| LayoutStyle {
display: display.clone(),
visibility: values.get(1).and_then(|v| string(*v.inner())).cloned(),
});
}
let mut current = parents.get(node_index).copied().unwrap_or(-1);
while let Ok(parent) = usize::try_from(current) {
if renders_below[parent] {
break;
}
renders_below[parent] = true;
current = parents.get(parent).copied().unwrap_or(-1);
}
}
let values = nodes.node_value.as_ref();
let mut pending = vec![false; backend.len()];
for (i, id) in backend.iter().enumerate() {
let Some(parent) = parents.get(i).and_then(|p| usize::try_from(*p).ok()) else {
continue;
};
let blank = types.get(i) == Some(&TEXT_NODE)
&& values
.and_then(|v| v.get(i))
.and_then(|v| string(*v.inner()))
.is_some_and(|t| t.trim().is_empty());
if blank {
pending[parent] = true;
continue;
}
if std::mem::take(&mut pending[parent]) {
whitespace_before.insert(*id.inner());
}
}
for (i, open) in pending.into_iter().enumerate() {
if open {
whitespace_last.insert(*backend[i].inner());
}
}
for (i, id) in backend.iter().enumerate() {
if types.get(i) != Some(&ELEMENT_NODE) {
continue;
}
let style = styles[i].take().unwrap_or_else(|| LayoutStyle {
display: if renders_below[i] { "contents" } else { "none" }.to_string(),
visibility: None,
});
by_backend.insert(*id.inner(), style);
}
}
Self {
by_backend,
whitespace_before,
whitespace_last,
}
}
}
async fn fetch_layout_styles(page: &Page) -> Result<LayoutStyles> {
let params = CaptureSnapshotParams::new(vec!["display".to_string(), "visibility".to_string()]);
let response = page
.execute(params)
.await
.map_err(|e| AuditError::AXTreeExtractionFailed {
reason: format!("DOMSnapshot.captureSnapshot fehlgeschlagen: {e}"),
})?;
Ok(LayoutStyles::from_snapshot(&response.result))
}
pub struct RenderedCdpDocument<'d> {
doc: &'d CdpDocument,
styles: &'d LayoutStyles,
}
impl Document for RenderedCdpDocument<'_> {
type N<'a>
= ArenaNode<'a>
where
Self: 'a;
fn root(&self) -> Self::N<'_> {
self.doc.root()
}
}
impl Rendering for RenderedCdpDocument<'_> {
fn computed_style<'n>(&'n self, node: Self::N<'n>) -> Option<ComputedStyle> {
let style = self
.styles
.by_backend
.get(&self.doc.backend_node_id(node)?)?;
Some(ComputedStyle {
color: None,
background_color: None,
font_size_px: None,
font_weight: None,
display: Some(style.display.clone()),
visibility: style.visibility.clone(),
})
}
fn bounds<'n>(&'n self, _node: Self::N<'n>) -> Option<Rect> {
None
}
}
impl Document for CdpDocument {
type N<'a>
= ArenaNode<'a>
where
Self: 'a;
fn root(&self) -> Self::N<'_> {
self.arena.root()
}
fn node_count(&self) -> Option<usize> {
self.arena.node_count()
}
}
impl Semantics for CdpDocument {
fn role<'n>(&'n self, node: Self::N<'n>) -> Option<String> {
self.facts(node)?.role.clone()
}
fn accessible_name<'n>(&'n self, node: Self::N<'n>) -> Option<String> {
self.facts(node)?.name.clone()
}
fn name_source<'n>(&'n self, node: Self::N<'n>) -> Option<SharedNameSource> {
self.facts(node)?.name_source
}
fn is_ignored<'n>(&'n self, node: Self::N<'n>) -> bool {
self.facts(node).is_some_and(|f| f.ignored)
}
}
fn map_name_source(src: NameSource) -> Option<SharedNameSource> {
match src {
NameSource::Contents => Some(SharedNameSource::Contents),
NameSource::Placeholder => Some(SharedNameSource::Placeholder),
NameSource::Title => Some(SharedNameSource::Title),
NameSource::Attribute | NameSource::RelatedElement => None,
}
}
fn shared_name_source(label: &str) -> Option<SharedNameSource> {
Some(match label {
"aria-label" => SharedNameSource::AriaLabel,
"aria-labelledby" => SharedNameSource::AriaLabelledBy,
"label" => SharedNameSource::Label,
"title" => SharedNameSource::Title,
"alt" => SharedNameSource::Alt,
"placeholder" => SharedNameSource::Placeholder,
"contents" => SharedNameSource::Contents,
"value" => SharedNameSource::Value,
_ => return None,
})
}
fn index_ax_tree(ax_tree: &AXTree) -> HashMap<i64, AxFacts> {
let mut map = HashMap::new();
for node in ax_tree.iter_all() {
let Some(backend) = node.backend_dom_node_id else {
continue;
};
map.insert(
backend,
AxFacts {
ax_node_id: node.node_id.clone(),
role: node.role.clone(),
name: node.name.clone(),
name_source: node.name_source.and_then(map_name_source),
chrome_name_source: None,
ignored: node.ignored,
},
);
}
map
}
fn local_name_of(node: &CdpNode) -> String {
if !node.local_name.is_empty() {
node.local_name.to_ascii_lowercase()
} else {
node.node_name.to_ascii_lowercase()
}
}
struct Walk<'l, 'n> {
builder: Option<a11y_dom::ArenaBuilder>,
backend_ids: Vec<Option<i64>>,
layout: Option<&'l LayoutStyles>,
by_backend: HashMap<i64, &'n CdpNode>,
}
fn index_nodes<'n>(node: &'n CdpNode, map: &mut HashMap<i64, &'n CdpNode>) {
map.insert(*node.backend_node_id.inner(), node);
for child in node.shadow_roots.iter().flatten() {
index_nodes(child, map);
}
for child in node.children.iter().flatten() {
index_nodes(child, map);
}
}
impl<'n> Walk<'_, 'n> {
fn record(&mut self, backend: Option<i64>) {
self.backend_ids.push(backend);
}
fn with<F>(&mut self, f: F)
where
F: FnOnce(a11y_dom::ArenaBuilder) -> a11y_dom::ArenaBuilder,
{
let b = self.builder.take().expect("builder liegt immer vor");
self.builder = Some(f(b));
}
fn element(&mut self, node: &CdpNode) {
let name = local_name_of(node);
self.with(|b| b.open(&name));
self.record(Some(*node.backend_node_id.inner()));
if let Some(attrs) = &node.attributes {
for pair in attrs.as_chunks::<2>().0 {
let (k, v) = (pair[0].to_ascii_lowercase(), pair[1].clone());
self.with(move |b| b.attr(&k, &v));
}
}
let mut is_host = false;
for shadow in node.shadow_roots.iter().flatten() {
is_host = true;
self.children(shadow);
}
let assigned: Vec<&'n CdpNode> = if name == "slot" {
node.distributed_nodes
.iter()
.flatten()
.filter_map(|b| self.by_backend.get(b.backend_node_id.inner()).copied())
.collect()
} else {
Vec::new()
};
if !assigned.is_empty() {
for child in assigned {
self.node(child);
}
} else if !is_host {
self.children(node);
}
self.with(|b| b.close());
}
fn children(&mut self, node: &CdpNode) {
let layout = self.layout;
let blank_before = |n: &CdpNode| {
layout.is_some_and(|l| l.whitespace_before.contains(n.backend_node_id.inner()))
};
for child in node.children.iter().flatten() {
if blank_before(child) {
self.blank();
}
self.node(child);
}
if layout.is_some_and(|l| l.whitespace_last.contains(node.backend_node_id.inner())) {
self.blank();
}
}
fn blank(&mut self) {
self.with(|b| b.text(" "));
self.record(None);
}
fn node(&mut self, node: &CdpNode) {
match node.node_type {
ELEMENT_NODE => self.element(node),
TEXT_NODE => {
let text = node.node_value.clone();
self.with(move |b| b.text(&text));
self.record(Some(*node.backend_node_id.inner()));
}
_ => {}
}
}
}
fn find_html(root: &CdpNode) -> Option<&CdpNode> {
if root.node_type == ELEMENT_NODE && local_name_of(root) == "html" {
return Some(root);
}
root.children
.iter()
.flatten()
.find_map(|child| find_html(child))
}
pub fn build_document(root: &CdpNode, ax_tree: &AXTree) -> Result<CdpDocument> {
build(root, ax_tree, None)
}
fn build(root: &CdpNode, ax_tree: &AXTree, layout: Option<LayoutStyles>) -> Result<CdpDocument> {
let html = find_html(root).ok_or_else(|| AuditError::AXTreeExtractionFailed {
reason: "DOM enthält kein <html>-Element".to_string(),
})?;
let mut by_backend = HashMap::new();
index_nodes(root, &mut by_backend);
let mut walk = Walk {
builder: Some(Arena::builder()),
backend_ids: Vec::new(),
layout: layout.as_ref(),
by_backend,
};
walk.node(html);
let arena = walk
.builder
.take()
.expect("builder liegt immer vor")
.build();
debug!(
nodes = arena.len(),
"DOM für die geteilten Regeln materialisiert"
);
Ok(CdpDocument {
arena,
backend_ids: walk.backend_ids,
ax: index_ax_tree(ax_tree),
layout,
})
}
pub async fn fetch_dom_document(page: &Page, ax_tree: &AXTree) -> Result<CdpDocument> {
let root = get_document(page).await?;
let doc = build_document(&root, ax_tree)?;
if doc.is_empty() {
warn!("DOM für die geteilten Regeln ist leer");
}
Ok(doc)
}
pub async fn fetch_dom_document_with_layout(page: &Page, ax_tree: &AXTree) -> Result<CdpDocument> {
let root = get_document(page).await?;
let layout = fetch_layout_styles(page).await?;
build(&root, ax_tree, Some(layout))
}
async fn get_document(page: &Page) -> Result<CdpNode> {
let params = GetDocumentParams {
depth: Some(-1),
pierce: Some(true),
};
let response = page
.execute(params)
.await
.map_err(|e| AuditError::AXTreeExtractionFailed {
reason: format!("DOM.getDocument fehlgeschlagen: {e}"),
})?;
Ok(response.result.root.clone())
}
#[cfg(test)]
mod tests {
use super::*;
use a11y_dom::{elements, NodeKind};
#[test]
fn layout_styles_leiten_contents_none_und_leerraum_ab() {
let snapshot: CaptureSnapshotReturns = serde_json::from_value(serde_json::json!({
"strings": ["block", "visible", "hidden", " ", "a"],
"documents": [{
"documentURL": 0, "title": 0, "baseURL": 0, "contentLanguage": 0,
"encodingName": 0, "publicId": 0, "systemId": 0, "frameId": 0,
"nodes": {
"parentIndex": [-1, 0, 1, 2, 1, 1, 5, 1],
"nodeType": [9, 1, 1, 3, 3, 1, 3, 3],
"nodeValue": [-1, -1, -1, 4, 3, -1, 4, 3],
"backendNodeId": [1, 2, 3, 4, 5, 6, 7, 8]
},
"layout": {
"nodeIndex": [1, 3],
"styles": [[0, 2], []],
"bounds": [[0, 0, 1, 1], [0, 0, 1, 1]],
"text": [-1, -1],
"stackingContexts": {"index": []}
},
"textBoxes": {"layoutIndex": [], "bounds": [], "start": [], "length": []}
}]
}))
.unwrap();
let styles = LayoutStyles::from_snapshot(&snapshot);
let get = |id| styles.by_backend.get(&id).cloned();
assert_eq!(
get(2),
Some(LayoutStyle {
display: "block".into(),
visibility: Some("hidden".into())
})
);
assert_eq!(get(3).map(|s| s.display), Some("contents".into()));
assert_eq!(get(6).map(|s| s.display), Some("none".into()));
assert_eq!(get(4), None, "Textknoten tragen keinen Stil");
assert_eq!(styles.whitespace_before, HashSet::from([6]));
assert_eq!(styles.whitespace_last, HashSet::from([2]));
}
fn cdp(json: serde_json::Value) -> CdpNode {
serde_json::from_value(json).expect("CDP-Knoten")
}
fn element(
backend: i64,
name: &str,
attrs: &[&str],
children: serde_json::Value,
) -> serde_json::Value {
serde_json::json!({
"nodeId": backend,
"backendNodeId": backend,
"nodeType": 1,
"nodeName": name.to_uppercase(),
"localName": name,
"nodeValue": "",
"attributes": attrs,
"children": children,
})
}
fn text(backend: i64, value: &str) -> serde_json::Value {
serde_json::json!({
"nodeId": backend,
"backendNodeId": backend,
"nodeType": 3,
"nodeName": "#text",
"localName": "",
"nodeValue": value,
})
}
fn beispielseite() -> CdpNode {
cdp(serde_json::json!({
"nodeId": 1,
"backendNodeId": 1,
"nodeType": 9,
"nodeName": "#document",
"localName": "",
"nodeValue": "",
"children": [
element(2, "html", &["lang", "de"], serde_json::json!([
element(3, "head", &[], serde_json::json!([
element(4, "title", &[], serde_json::json!([text(5, "Beispiel")])),
])),
element(6, "body", &[], serde_json::json!([
element(7, "img", &["src", "logo.png"], serde_json::json!([])),
element(8, "a", &["href", "/x"], serde_json::json!([text(9, "Mehr")])),
])),
]))
]
}))
}
fn leerer_ax() -> AXTree {
AXTree::new()
}
#[test]
fn wurzel_ist_das_html_element_nicht_der_dokumentknoten() {
let doc = build_document(&beispielseite(), &leerer_ax()).unwrap();
assert_eq!(doc.root().local_name(), "html");
assert_eq!(doc.root().attr("lang"), Some("de"));
}
#[test]
fn fehlendes_html_element_ist_ein_fehler() {
let ohne_html = cdp(serde_json::json!({
"nodeId": 1, "backendNodeId": 1, "nodeType": 9,
"nodeName": "#document", "localName": "", "nodeValue": "",
"children": []
}));
assert!(build_document(&ohne_html, &leerer_ax()).is_err());
}
#[test]
fn attribute_und_text_kommen_mit() {
let doc = build_document(&beispielseite(), &leerer_ax()).unwrap();
let img = elements(&doc).find(|n| n.is_element("img")).unwrap();
assert_eq!(img.attr("src"), Some("logo.png"));
assert!(img.attr("alt").is_none());
let titel = elements(&doc).find(|n| n.is_element("title")).unwrap();
assert_eq!(a11y_dom::subtree_text(titel).trim(), "Beispiel");
}
#[test]
fn backend_ids_bleiben_mit_den_arena_indizes_im_gleichschritt() {
let doc = build_document(&beispielseite(), &leerer_ax()).unwrap();
for (erwartet, name) in [(2, "html"), (3, "head"), (4, "title"), (7, "img"), (8, "a")] {
let n = elements(&doc).find(|n| n.is_element(name)).unwrap();
assert_eq!(
doc.backend_node_id(n),
Some(erwartet),
"Backend-ID von <{name}>"
);
}
let text_knoten: Vec<_> = a11y_dom::self_and_descendants(doc.root())
.filter(|n| n.kind() == NodeKind::Text)
.collect();
assert_eq!(text_knoten.len(), 2);
assert_eq!(doc.backend_node_id(text_knoten[0]), Some(5));
assert_eq!(doc.backend_node_id(text_knoten[1]), Some(9));
}
#[test]
fn shadow_root_kinder_haengen_unter_dem_host() {
let mit_shadow = cdp(serde_json::json!({
"nodeId": 1, "backendNodeId": 1, "nodeType": 9,
"nodeName": "#document", "localName": "", "nodeValue": "",
"children": [
element(2, "html", &[], serde_json::json!([
element(3, "body", &[], serde_json::json!([
{
"nodeId": 4, "backendNodeId": 4, "nodeType": 1,
"nodeName": "MY-CARD", "localName": "my-card",
"nodeValue": "", "children": [],
"shadowRoots": [{
"nodeId": 5, "backendNodeId": 5, "nodeType": 11,
"nodeName": "#document-fragment", "localName": "",
"nodeValue": "",
"children": [element(6, "button", &[], serde_json::json!([]))]
}]
}
]))
]))
]
}));
let doc = build_document(&mit_shadow, &leerer_ax()).unwrap();
let button = elements(&doc).find(|n| n.is_element("button")).unwrap();
assert_eq!(doc.backend_node_id(button), Some(6));
assert_eq!(button.parent().unwrap().local_name(), "my-card");
}
#[test]
fn slot_nimmt_die_zugewiesenen_light_kinder_auf() {
let slot = serde_json::json!({
"nodeId": 7, "backendNodeId": 7, "nodeType": 1,
"nodeName": "SLOT", "localName": "slot", "nodeValue": "", "children": [],
"distributedNodes": [
{"nodeType": 1, "nodeName": "MUSE-LINK", "backendNodeId": 10},
{"nodeType": 1, "nodeName": "MUSE-LINK", "backendNodeId": 11}
]
});
let shadow = serde_json::json!({
"nodeId": 5, "backendNodeId": 5, "nodeType": 11,
"nodeName": "#document-fragment", "localName": "", "nodeValue": "",
"children": [element(6, "ul", &[], serde_json::json!([slot]))]
});
let light = serde_json::json!([
element(
10,
"muse-link",
&["role", "listitem"],
serde_json::json!([])
),
element(
11,
"muse-link",
&["role", "listitem"],
serde_json::json!([])
),
element(12, "span", &["slot", "unbenutzt"], serde_json::json!([]))
]);
let list = serde_json::json!({
"nodeId": 4, "backendNodeId": 4, "nodeType": 1,
"nodeName": "MUSE-LINK-LIST", "localName": "muse-link-list", "nodeValue": "",
"children": light, "shadowRoots": [shadow]
});
let body = element(3, "body", &[], serde_json::json!([list]));
let host = cdp(serde_json::json!({
"nodeId": 1, "backendNodeId": 1, "nodeType": 9,
"nodeName": "#document", "localName": "", "nodeValue": "",
"children": [element(2, "html", &[], serde_json::json!([body]))]
}));
let doc = build_document(&host, &leerer_ax()).unwrap();
let items: Vec<_> = elements(&doc)
.filter(|n| n.is_element("muse-link"))
.collect();
assert_eq!(items.len(), 2);
for item in items {
assert_eq!(item.parent().unwrap().local_name(), "slot");
assert_eq!(item.parent().unwrap().parent().unwrap().local_name(), "ul");
}
assert!(!elements(&doc).any(|n| n.is_element("span")));
}
#[test]
fn slot_ohne_zuweisung_zeigt_ersatzinhalt() {
let host = cdp(serde_json::json!({
"nodeId": 1, "backendNodeId": 1, "nodeType": 9,
"nodeName": "#document", "localName": "", "nodeValue": "",
"children": [
element(2, "html", &[], serde_json::json!([
element(3, "body", &[], serde_json::json!([
{
"nodeId": 4, "backendNodeId": 4, "nodeType": 1,
"nodeName": "MY-CARD", "localName": "my-card",
"nodeValue": "", "children": [],
"shadowRoots": [{
"nodeId": 5, "backendNodeId": 5, "nodeType": 11,
"nodeName": "#document-fragment", "localName": "",
"nodeValue": "",
"children": [element(6, "slot", &[], serde_json::json!([
element(8, "button", &[], serde_json::json!([]))
]))]
}]
}
]))
]))
]
}));
let doc = build_document(&host, &leerer_ax()).unwrap();
let button = elements(&doc).find(|n| n.is_element("button")).unwrap();
assert_eq!(button.parent().unwrap().local_name(), "slot");
}
fn ax_knoten(
backend: i64,
role: &str,
name: Option<&str>,
ignored: bool,
) -> super::super::AXNode {
super::super::AXNode {
node_id: format!("ax-{backend}"),
ignored,
ignored_reasons: Vec::new(),
role: Some(role.to_string()),
name: name.map(str::to_string),
name_source: None,
description: None,
value: None,
properties: Vec::new(),
child_ids: Vec::new(),
parent_id: None,
backend_dom_node_id: Some(backend),
}
}
#[test]
fn semantics_liest_die_nativen_ax_werte() {
let ax = AXTree::from_nodes(vec![
ax_knoten(7, "image", Some("Firmenlogo"), false),
ax_knoten(8, "link", None, false),
]);
let doc = build_document(&beispielseite(), &ax).unwrap();
let img = elements(&doc).find(|n| n.is_element("img")).unwrap();
assert_eq!(doc.role(img).as_deref(), Some("image"));
assert_eq!(doc.accessible_name(img).as_deref(), Some("Firmenlogo"));
let link = elements(&doc).find(|n| n.is_element("a")).unwrap();
assert_eq!(doc.role(link).as_deref(), Some("link"));
assert!(doc.accessible_name(link).is_none());
let head = elements(&doc).find(|n| n.is_element("head")).unwrap();
assert!(doc.role(head).is_none());
}
#[test]
fn ignorierte_knoten_werden_als_solche_gemeldet() {
let ax = AXTree::from_nodes(vec![ax_knoten(8, "link", None, true)]);
let doc = build_document(&beispielseite(), &ax).unwrap();
let link = elements(&doc).find(|n| n.is_element("a")).unwrap();
assert!(doc.is_ignored(link));
}
#[test]
fn geteilte_regeln_laufen_und_vergeben_die_geteilten_kennungen() {
let ax = AXTree::from_nodes(vec![
ax_knoten(7, "image", Some("Firmenlogo"), false),
ax_knoten(8, "link", None, false),
]);
let doc = build_document(&beispielseite(), &ax).unwrap();
let report = a11y_rules::run_with_semantics(&doc);
let ids: Vec<&str> = report.findings.iter().map(|f| f.rule_id.as_str()).collect();
assert!(ids.contains(&"images/alt-missing"), "gefunden: {ids:?}");
assert!(ids.contains(&"links/name-missing"), "gefunden: {ids:?}");
assert!(!ids.iter().any(|id| id.starts_with("document/")), "{ids:?}");
let mut nicht_gelaufen: Vec<&str> = report
.rule_runs
.iter()
.filter(|r| r.not_run.is_some())
.map(|r| r.rule_id.as_str())
.collect();
nicht_gelaufen.sort_unstable();
assert_eq!(
nicht_gelaufen,
vec!["contrast/text-insufficient", "contrast/text-undetermined"],
"unerwartet nicht gelaufen: {nicht_gelaufen:?}"
);
for f in &report.findings {
assert!(
report.rule_runs.iter().any(|r| r.rule_id == f.rule_id),
"kein RuleRun zu {}",
f.rule_id
);
}
}
#[test]
fn befund_laesst_sich_auf_eine_backend_id_zurueckfuehren() {
let ax = AXTree::from_nodes(vec![ax_knoten(8, "link", None, false)]);
let doc = build_document(&beispielseite(), &ax).unwrap();
let report = a11y_rules::run_with_semantics(&doc);
let befund = report
.findings
.iter()
.find(|f| f.rule_id == "links/name-missing")
.expect("Linkbefund");
let idx: u32 = befund.location.node.as_deref().unwrap().parse().unwrap();
let knoten = doc.arena.get(a11y_dom::NodeId(idx)).unwrap();
assert_eq!(doc.backend_node_id(knoten), Some(8));
}
fn ax_mit_quelle(backend: i64, source: NameSource) -> AXTree {
AXTree::from_nodes(vec![a11y_perception::AXNode {
node_id: format!("ax{backend}"),
ignored: false,
ignored_reasons: Vec::new(),
role: Some("image".into()),
name: Some("Logo".into()),
name_source: Some(source),
description: None,
value: None,
properties: Vec::new(),
child_ids: Vec::new(),
parent_id: None,
backend_dom_node_id: Some(backend),
}])
}
#[test]
fn feine_namensquellen_ersetzen_die_grobe_zuordnung() {
let ax = ax_mit_quelle(7, NameSource::Attribute);
let doc = build_document(&beispielseite(), &ax).unwrap();
let img = elements(&doc).find(|n| n.is_element("img")).unwrap();
assert_eq!(doc.name_source(img), None);
assert_eq!(doc.chrome_name_source(img), None);
for (label, erwartet) in [
("alt", Some(SharedNameSource::Alt)),
("aria-label", Some(SharedNameSource::AriaLabel)),
("aria-labelledby", Some(SharedNameSource::AriaLabelledBy)),
("label", Some(SharedNameSource::Label)),
("value", Some(SharedNameSource::Value)),
("figcaption", None),
] {
let doc = build_document(&beispielseite(), &ax)
.unwrap()
.with_chrome_name_sources(&HashMap::from([(7, label.to_string())]));
let img = elements(&doc).find(|n| n.is_element("img")).unwrap();
assert_eq!(doc.name_source(img), erwartet, "{label}");
assert_eq!(doc.chrome_name_source(img), Some(label));
}
}
#[test]
fn iframe_inhalt_wird_nicht_betreten() {
let mit_iframe = cdp(serde_json::json!({
"nodeId": 1, "backendNodeId": 1, "nodeType": 9,
"nodeName": "#document", "localName": "", "nodeValue": "",
"children": [
element(2, "html", &[], serde_json::json!([
element(3, "body", &[], serde_json::json!([
{
"nodeId": 4, "backendNodeId": 4, "nodeType": 1,
"nodeName": "IFRAME", "localName": "iframe",
"nodeValue": "", "children": [],
"contentDocument": {
"nodeId": 5, "backendNodeId": 5, "nodeType": 9,
"nodeName": "#document", "localName": "", "nodeValue": "",
"children": [element(6, "html", &[], serde_json::json!([
element(7, "p", &[], serde_json::json!([]))
]))]
}
}
]))
]))
]
}));
let doc = build_document(&mit_iframe, &leerer_ax()).unwrap();
assert!(elements(&doc).any(|n| n.is_element("iframe")));
assert!(
!elements(&doc).any(|n| n.is_element("p")),
"Inhalt des iframe-Dokuments darf nicht im Baum stehen"
);
}
}