use chromiumoxide::cdp::browser_protocol::dom::{
BackendNodeId, DescribeNodeParams, GetOuterHtmlParams, ResolveNodeParams,
};
use chromiumoxide::cdp::js_protocol::runtime::CallFunctionOnParams;
use chromiumoxide::Page;
use tracing::warn;
use super::code_gen::{generate_suggested_code, truncate_html};
use super::tree::AXTree;
use crate::wcag::types::Violation;
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 => {
violation.kind = crate::wcag::types::FindingKind::Warning;
continue;
}
};
match describe_node_selector(page, backend_id).await {
Some(sel) => {
violation
.evidence
.push(crate::wcag::types::ViolationEvidence::ax_tree(&sel));
violation.selector = Some(sel);
}
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, backend_id).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_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 describe_node_selector(page: &Page, backend_node_id: i64) -> Option<String> {
let params = DescribeNodeParams::builder()
.backend_node_id(BackendNodeId::new(backend_node_id))
.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()
};
if selector == tag {
if let Some(ctx) = parent_context_selector(page, backend_node_id).await {
return Some(ctx);
}
}
Some(selector)
}
async fn parent_context_selector(page: &Page, backend_node_id: i64) -> Option<String> {
let resolve = ResolveNodeParams::builder()
.backend_node_id(BackendNodeId::new(backend_node_id))
.build();
let resolved = page
.execute(resolve)
.await
.map_err(|e| warn!("DOM.resolveNode failed: {}", e))
.ok()?;
let object_id = resolved.object.object_id.clone()?;
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, backend_node_id: i64) -> Option<String> {
let params = GetOuterHtmlParams::builder()
.backend_node_id(BackendNodeId::new(backend_node_id))
.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 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>"#));
}
}