use chromiumoxide::cdp::js_protocol::runtime::EvaluateParams;
use chromiumoxide::Page;
use crate::audit::normalized::{InteractiveFinding, JourneyStep, JourneyTrace};
use crate::error::Result;
use crate::interaction::{focus, stability};
use crate::taxonomy::Severity;
const INJECT_SPA_OBSERVER_JS: &str = r#"
(function() {
window.__ams_spa_nav_count = 0;
var orig_push = history.pushState.bind(history);
var orig_replace = history.replaceState.bind(history);
history.pushState = function() {
window.__ams_spa_nav_count++;
return orig_push.apply(this, arguments);
};
history.replaceState = function() {
window.__ams_spa_nav_count++;
return orig_replace.apply(this, arguments);
};
window.addEventListener('popstate', function() {
window.__ams_spa_nav_count++;
});
true;
})()
"#;
const COLLECT_SPA_LINKS_JS: &str = r#"
(function() {
var origin = window.location.origin;
var currentHref = window.location.href;
var links = Array.from(document.querySelectorAll('a[href]'));
var candidates = [];
links.forEach(function(a) {
var href = a.href;
if (!href.startsWith(origin)) return; // external
if (href === currentHref) return; // same page
if (a.getAttribute('href').startsWith('#')) return; // hash-only
if (a.download) return; // download
if (a.target === '_blank') return; // new tab
// Only path-different links (ignore query/hash for classification).
var aPath = new URL(href).pathname;
var curPath = new URL(currentHref).pathname;
if (aPath === curPath) return; // same path, different query
candidates.push({ selector: cssPath(a), href: href });
if (candidates.length >= 5) return;
});
function cssPath(el) {
if (el.id) return '#' + el.id;
var parts = [];
var n = el;
while (n && n.nodeType === 1 && parts.length < 5) {
var tag = n.nodeName.toLowerCase();
if (n.id) { parts.unshift(tag + '#' + n.id); break; }
parts.unshift(tag);
n = n.parentNode;
}
return parts.join(' > ');
}
return candidates;
})()
"#;
async fn eval_bool(page: &Page, js: &str) -> Option<bool> {
let params = EvaluateParams::builder()
.expression(js.to_string())
.return_by_value(true)
.build()
.ok()?;
let result = page.execute(params).await.ok()?;
result.result.result.value?.as_bool()
}
async fn eval_int(page: &Page, js: &str) -> Option<i64> {
let params = EvaluateParams::builder()
.expression(js.to_string())
.return_by_value(true)
.build()
.ok()?;
let result = page.execute(params).await.ok()?;
result.result.result.value?.as_i64()
}
async fn eval_str(page: &Page, js: &str) -> Option<String> {
let params = EvaluateParams::builder()
.expression(js.to_string())
.return_by_value(true)
.build()
.ok()?;
let result = page.execute(params).await.ok()?;
result.result.result.value?.as_str().map(|s| s.to_string())
}
async fn collect_spa_link_candidates(page: &Page) -> Vec<(String, String)> {
let params = EvaluateParams::builder()
.expression(COLLECT_SPA_LINKS_JS.to_string())
.return_by_value(true)
.build();
let Ok(params) = params else {
return Vec::new();
};
let Ok(result) = page.execute(params).await else {
return Vec::new();
};
let Some(val) = result.result.result.value else {
return Vec::new();
};
let Some(arr) = val.as_array() else {
return Vec::new();
};
arr.iter()
.filter_map(|item| {
let selector = item.get("selector")?.as_str()?.to_string();
let href = item.get("href")?.as_str()?.to_string();
Some((selector, href))
})
.collect()
}
pub async fn run(
page: &Page,
initial_url: &str,
) -> Result<Option<(JourneyTrace, Vec<InteractiveFinding>)>> {
let journey_name = "spa_navigation".to_string();
let mut trace = JourneyTrace {
journey: journey_name.clone(),
steps: Vec::new(),
};
let observer_ok = eval_bool(page, INJECT_SPA_OBSERVER_JS)
.await
.unwrap_or(false);
if !observer_ok {
tracing::debug!("spa_navigation: observer injection failed");
return Ok(None);
}
let candidates = collect_spa_link_candidates(page).await;
if candidates.is_empty() {
tracing::debug!("spa_navigation: no candidate links found");
return Ok(None);
}
let title_before = eval_str(page, "document.title").await.unwrap_or_default();
let h1_before = eval_str(
page,
"document.querySelector('h1')?.textContent?.trim() ?? ''",
)
.await
.unwrap_or_default();
trace.steps.push(JourneyStep {
action: "baseline".to_string(),
target: None,
focus: None,
result: Some(format!("title:{title_before:?}, h1:{h1_before:?}")),
snapshot_label: Some("before_spa_nav".to_string()),
});
let mut nav_detected = false;
for (selector, href) in &candidates {
let nav_count_before = eval_int(page, "window.__ams_spa_nav_count ?? 0")
.await
.unwrap_or(0);
let click_js = format!(
r#"(function() {{
var el = document.querySelector({selector_json});
if (el) {{ el.click(); return true; }}
return false;
}})()"#,
selector_json = serde_json::to_string(selector).unwrap_or_default()
);
let clicked = eval_bool(page, &click_js).await.unwrap_or(false);
if !clicked {
continue;
}
trace.steps.push(JourneyStep {
action: "click_spa_link".to_string(),
target: Some(selector.clone()),
focus: None,
result: Some(format!("href:{href}")),
snapshot_label: Some("after_spa_click".to_string()),
});
stability::settle(page).await?;
let nav_count_after = eval_int(page, "window.__ams_spa_nav_count ?? 0")
.await
.unwrap_or(0);
if nav_count_after > nav_count_before {
nav_detected = true;
break;
}
}
if !nav_detected {
tracing::debug!("spa_navigation: no History-API navigation observed on {initial_url}");
return Ok(None);
}
stability::settle(page).await?;
let title_after = eval_str(page, "document.title").await.unwrap_or_default();
let h1_after = eval_str(
page,
"document.querySelector('h1')?.textContent?.trim() ?? ''",
)
.await
.unwrap_or_default();
let focus_snap = focus::capture_focus(page).await?;
let focus_sel = focus_snap.selector.clone();
let focus_on_body = focus_sel.is_none()
|| focus_sel
.as_deref()
.map(|s| {
let l = s.to_lowercase();
l == "body" || l == "html"
})
.unwrap_or(true);
let title_changed = !title_after.is_empty() && title_after != title_before;
let heading_changed = !h1_after.is_empty() && h1_after != h1_before;
let focus_moved = !focus_on_body;
trace.steps.push(JourneyStep {
action: "check_spa_announcement".to_string(),
target: None,
focus: focus_sel,
result: Some(format!(
"title_changed:{title_changed}, heading_changed:{heading_changed}, focus_moved:{focus_moved}"
)),
snapshot_label: Some("after_spa_nav".to_string()),
});
let mut findings = Vec::new();
if !title_changed && !heading_changed && !focus_moved {
findings.push(InteractiveFinding {
category: "SpaNavigation".to_string(),
maps_to_finding: None,
severity: Severity::High,
journey: journey_name.clone(),
before_snapshot_label: Some("before_spa_nav".to_string()),
after_snapshot_label: Some("after_spa_nav".to_string()),
message: format!(
"Nach einer SPA-Navigation ändert sich weder der Seiten-Titel \
(vorher: {title_before:?}) noch der H1-Heading, und der Fokus \
bleibt an derselben Stelle. Screenreader kündigen den neuen \
Inhalt deshalb nicht an."
),
fix_suggestion: Some(
"Nach jeder Client-seitigen Navigation: (1) document.title aktualisieren, \
(2) Fokus auf das <main>-Element oder den neuen H1-Heading setzen, \
(3) alternativ eine aria-live-Region mit dem neuen Seitennamen befüllen."
.to_string(),
),
});
} else if !title_changed {
findings.push(InteractiveFinding {
category: "SpaNavigation".to_string(),
maps_to_finding: None,
severity: Severity::Medium,
journey: journey_name.clone(),
before_snapshot_label: Some("before_spa_nav".to_string()),
after_snapshot_label: Some("after_spa_nav".to_string()),
message: format!(
"Nach SPA-Navigation bleibt document.title unverändert ({title_before:?}). \
Screenreader kündigen Seitenwechsel oft primär über den Titel an."
),
fix_suggestion: Some(
"document.title nach jeder Client-seitigen Navigation auf den neuen \
Seitennamen setzen."
.to_string(),
),
});
} else if !focus_moved {
findings.push(InteractiveFinding {
category: "SpaNavigation".to_string(),
maps_to_finding: None,
severity: Severity::Medium,
journey: journey_name.clone(),
before_snapshot_label: Some("before_spa_nav".to_string()),
after_snapshot_label: Some("after_spa_nav".to_string()),
message: "Nach SPA-Navigation wird der Fokus nicht auf den neuen Hauptbereich \
gesetzt. Tastatur-Nutzer müssen manuell zum neuen Inhalt navigieren."
.to_string(),
fix_suggestion: Some(
"Nach der Navigation den Fokus auf das <main>-Element oder den ersten \
H1-Heading des neuen Inhalts setzen."
.to_string(),
),
});
}
Ok(Some((trace, findings)))
}