use chromiumoxide::cdp::browser_protocol::dom::{
BackendNodeId, DescribeNodeParams, GetOuterHtmlParams, ResolveNodeParams,
};
use chromiumoxide::cdp::js_protocol::runtime::{CallFunctionOnParams, RemoteObjectId};
use chromiumoxide::Page;
use tracing::warn;
use super::code_gen::{generate_suggested_code, truncate_html};
use super::tree::AXTree;
use crate::wcag::types::Violation;
enum NodeRef {
Backend(i64),
Object(RemoteObjectId),
}
pub async fn enrich_violations_with_page(
page: &Page,
violations: &mut [Violation],
ax_tree: &AXTree,
) {
for violation in violations.iter_mut() {
if violation.selector.is_some() {
continue; }
let backend_id = match ax_tree
.get_node(&violation.node_id)
.and_then(|n| n.backend_dom_node_id)
{
Some(id) => id,
None => {
if !is_page_level_placeholder_node_id(&violation.node_id) {
violation.kind = crate::wcag::types::FindingKind::Warning;
}
continue;
}
};
let node_ref = effective_node_ref(page, backend_id).await;
match describe_node_selector(page, &node_ref).await {
Some((sel, dom_path)) => {
violation
.evidence
.push(crate::wcag::types::ViolationEvidence::ax_tree(&sel));
violation.selector = Some(sel);
if let Some(path) = dom_path.filter(|p| !p.is_empty()) {
violation
.evidence
.push(crate::wcag::types::ViolationEvidence {
source: "ax_tree".to_string(),
field: Some("dom_path".to_string()),
value: Some(path),
});
}
}
None => {
warn!(
"Could not resolve backend DOM node {} for violation {}",
backend_id, violation.rule
);
violation.kind = crate::wcag::types::FindingKind::Warning;
}
}
if let Some(raw_html) = get_outer_html(page, &node_ref).await {
if violation.rule == "1.1.1"
&& (is_lazyload_image_placeholder(&raw_html) || is_decorative_svg_icon(&raw_html))
{
violation.kind = crate::wcag::types::FindingKind::Warning;
}
let snippet = truncate_html(raw_html);
let suggested = generate_suggested_code(
&violation.rule,
Some(&snippet),
violation.role.as_deref(),
violation.fix_suggestion.as_deref(),
);
violation.html_snippet = Some(snippet);
violation.suggested_code = suggested;
} else {
violation.suggested_code = generate_suggested_code(
&violation.rule,
None,
violation.role.as_deref(),
violation.fix_suggestion.as_deref(),
);
}
}
}
fn is_page_level_placeholder_node_id(node_id: &str) -> bool {
!node_id.is_empty() && !node_id.bytes().all(|b| b.is_ascii_digit())
}
fn is_lazyload_image_placeholder(html: &str) -> bool {
let lower = html.to_ascii_lowercase();
if !lower.trim_start().starts_with("<img") {
return false;
}
const LAZY_MARKERS: &[&str] = &[
"data-src",
"data-srcset",
"data-lazy",
"data-original",
"lazyload",
];
LAZY_MARKERS.iter().any(|marker| lower.contains(marker))
}
fn is_decorative_svg_icon(html: &str) -> bool {
let lower = html.to_ascii_lowercase();
lower.trim_start().starts_with("<svg")
&& lower.contains("<use")
&& !lower.contains("<title")
&& !lower.contains("aria-label")
}
async fn effective_node_ref(page: &Page, backend_node_id: i64) -> NodeRef {
let fallback = NodeRef::Backend(backend_node_id);
let Some(object_id) = resolve_object_id(page, &fallback).await else {
return fallback;
};
let js = r#"function() {
var root = this.getRootNode();
return (root && root.host) ? root.host : null;
}"#;
let Ok(call) = CallFunctionOnParams::builder()
.function_declaration(js)
.object_id(object_id)
.build()
else {
return fallback;
};
let Ok(result) = page.execute(call).await else {
return fallback;
};
match result.result.result.object_id.clone() {
Some(host_object_id) => NodeRef::Object(host_object_id),
None => fallback,
}
}
async fn resolve_object_id(page: &Page, node_ref: &NodeRef) -> Option<RemoteObjectId> {
match node_ref {
NodeRef::Object(object_id) => Some(object_id.clone()),
NodeRef::Backend(id) => {
let resolve = ResolveNodeParams::builder()
.backend_node_id(BackendNodeId::new(*id))
.build();
let resolved = page
.execute(resolve)
.await
.map_err(|e| warn!("DOM.resolveNode failed: {}", e))
.ok()?;
resolved.object.object_id.clone()
}
}
}
async fn describe_node_selector(
page: &Page,
node_ref: &NodeRef,
) -> Option<(String, Option<String>)> {
let builder = DescribeNodeParams::builder();
let builder = match node_ref {
NodeRef::Backend(id) => builder.backend_node_id(BackendNodeId::new(*id)),
NodeRef::Object(object_id) => builder.object_id(object_id.clone()),
};
let params = builder.build();
let response = page
.execute(params)
.await
.map_err(|e| warn!("DOM.describeNode failed: {}", e))
.ok()?;
let node = &response.node;
let tag = node.node_name.to_lowercase();
let attrs: Vec<String> = node.attributes.as_deref().unwrap_or(&[]).to_vec();
let attr_pairs: Vec<(&str, &str)> = attrs
.chunks(2)
.filter_map(|p| {
if p.len() == 2 {
Some((p[0].as_str(), p[1].as_str()))
} else {
None
}
})
.collect();
let get = |name: &str| -> Option<&str> {
attr_pairs.iter().find(|(k, _)| *k == name).map(|(_, v)| *v)
};
let base = if let Some(id) = get("id").filter(|v| !v.is_empty()) {
format!("{}#{}", tag, id)
} else if let Some(cls) = get("class").filter(|v| !v.is_empty()) {
let first = cls.split_whitespace().next().unwrap_or("");
format!("{}.{}", tag, first)
} else {
tag.clone()
};
let hint = match tag.as_str() {
"img" => get("src").map(|s| format!("src: {}", short_url(s))),
"a" => get("href").map(|s| format!("href: {}", short_url(s))),
"input" => get("type")
.map(|t| format!("type={}", t))
.or_else(|| get("name").map(|n| format!("name={}", n))),
"svg" => get("viewBox")
.map(|vb| format!("viewBox={}", vb))
.or_else(|| get("width").map(|w| format!("width={}", w))),
_ => None,
};
let selector = if let Some(h) = hint {
format!("{} [{}]", base, h)
} else {
base.clone()
};
let dom_path = parent_context_selector(page, node_ref).await;
if selector == tag {
if let Some(ctx) = &dom_path {
return Some((ctx.clone(), dom_path));
}
}
Some((selector, dom_path))
}
async fn parent_context_selector(page: &Page, node_ref: &NodeRef) -> Option<String> {
let object_id = resolve_object_id(page, node_ref).await?;
let js = r#"function() {
function seg(el) {
if (!el) return '';
const tag = el.tagName.toLowerCase();
if (el.id) return tag + '#' + el.id;
const cls = (typeof el.className === 'string' && el.className.trim())
? '.' + el.className.trim().split(/\s+/)[0]
: '';
// Add :nth-of-type when there are multiple siblings with the same tag
const p = el.parentElement;
if (p) {
const siblings = Array.from(p.children).filter(c => c.tagName === el.tagName);
if (siblings.length > 1) {
const idx = siblings.indexOf(el) + 1;
return tag + cls + ':nth-of-type(' + idx + ')';
}
}
return tag + cls;
}
const parts = [];
let cur = this;
for (let i = 0; i < 3 && cur && cur !== document.documentElement && cur !== document.body; i++) {
parts.unshift(seg(cur));
// Stop early if this segment is already unique (has id or unique class)
if (cur.id) break;
cur = cur.parentElement;
}
return parts.join(' > ');
}"#;
let call = match CallFunctionOnParams::builder()
.function_declaration(js)
.object_id(object_id)
.return_by_value(true)
.build()
{
Ok(p) => p,
Err(e) => {
warn!("CallFunctionOn build failed: {}", e);
return None;
}
};
let result = page
.execute(call)
.await
.map_err(|e| warn!("Runtime.callFunctionOn failed: {}", e))
.ok()?;
result
.result
.result
.value
.as_ref()
.and_then(serde_json::Value::as_str)
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
}
async fn get_outer_html(page: &Page, node_ref: &NodeRef) -> Option<String> {
let builder = GetOuterHtmlParams::builder();
let builder = match node_ref {
NodeRef::Backend(id) => builder.backend_node_id(BackendNodeId::new(*id)),
NodeRef::Object(object_id) => builder.object_id(object_id.clone()),
};
let params = builder.build();
let response = page
.execute(params)
.await
.map_err(|e| warn!("DOM.getOuterHTML failed: {}", e))
.ok()?;
let html = response.outer_html.trim().to_string();
if html.is_empty() {
None
} else {
Some(html)
}
}
fn short_url(s: &str) -> String {
if s.len() > 55 {
format!("\u{2026}{}", &s[s.len() - 52..])
} else {
s.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn short_url_truncates_long_src() {
let long = "https://example.com/assets/images/hero-banner-2024-large.jpg";
let result = short_url(long);
assert!(result.starts_with('\u{2026}'));
assert!(result.chars().count() <= 53);
}
#[test]
fn short_url_keeps_short_src() {
let short = "/img/logo.png";
assert_eq!(short_url(short), short);
}
#[test]
fn page_level_placeholder_node_ids_are_recognized() {
assert!(is_page_level_placeholder_node_id("page"));
assert!(is_page_level_placeholder_node_id("root"));
assert!(is_page_level_placeholder_node_id("document"));
assert!(is_page_level_placeholder_node_id("stylesheet"));
assert!(is_page_level_placeholder_node_id("some-future-placeholder"));
}
#[test]
fn real_ax_node_ids_are_not_page_level_placeholders() {
assert!(!is_page_level_placeholder_node_id("42"));
assert!(!is_page_level_placeholder_node_id("1"));
assert!(!is_page_level_placeholder_node_id(""));
}
#[test]
fn lazyload_placeholder_detection() {
assert!(is_lazyload_image_placeholder(
r#"<img class="lazyload" alt="" src="data:image/svg+xml,...">"#
));
assert!(is_lazyload_image_placeholder(
r#"<img data-src="/real.jpg" src="/placeholder.gif">"#
));
assert!(is_lazyload_image_placeholder(
r#"<img data-srcset="/real-2x.jpg 2x">"#
));
assert!(!is_lazyload_image_placeholder(
r#"<img loading="lazy" src="/real.jpg">"#
));
assert!(!is_lazyload_image_placeholder(r#"<img src="/real.jpg">"#));
assert!(!is_lazyload_image_placeholder(
r#"<div data-src="/x.jpg"></div>"#
));
}
#[test]
fn decorative_svg_icon_detection() {
assert!(is_decorative_svg_icon(
r##"<svg width="16" height="16"><use xlink:href="#spon-text-m"></use></svg>"##
));
assert!(!is_decorative_svg_icon(
r##"<svg aria-label="Search"><use xlink:href="#search"></use></svg>"##
));
assert!(!is_decorative_svg_icon(
r#"<svg><title>Chart</title><path d="…"/></svg>"#
));
assert!(!is_decorative_svg_icon(r#"<img src="/x.jpg">"#));
assert!(!is_decorative_svg_icon(r#"<svg><path d="…"/></svg>"#));
}
}