use chromiumoxide::cdp::js_protocol::runtime::EvaluateParams;
use chromiumoxide::Page;
use super::eval_bool;
use crate::accessibility::{AXSnapshot, AXTree, AXTreeDiff};
use crate::audit::normalized::{
InteractiveFinding, InteractiveFindingKind, InteractiveFindingValues, JourneyStep, JourneyTrace,
};
use crate::error::Result;
use crate::interaction::stability;
use crate::taxonomy::Severity;
fn top_headings(tree: &AXTree) -> Vec<String> {
tree.iter()
.filter(|n| n.heading_level() == Some(1))
.filter_map(|n| n.name.as_ref().map(|s| s.trim().to_string()))
.filter(|s| !s.is_empty())
.collect()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Signals {
title_changed: bool,
heading_changed: bool,
focus_moved: bool,
}
fn signals(before: &AXSnapshot, after: &AXSnapshot, diff: &AXTreeDiff) -> Signals {
Signals {
title_changed: diff.title_changed.is_some() && !after.document_title.trim().is_empty(),
heading_changed: {
let b = top_headings(&before.tree);
let a = top_headings(&after.tree);
!a.is_empty() && a != b
},
focus_moved: after.focus.active_backend_node_id.is_some()
&& after.focus.active_backend_node_id != before.focus.active_backend_node_id,
}
}
async fn snapshot(page: &Page, label: &str) -> Option<AXSnapshot> {
match crate::accessibility::capture_snapshot(page, label, 0).await {
Ok(snapshot) => Some(snapshot),
Err(e) => {
tracing::warn!("spa_navigation: Aufnahme '{label}' fehlgeschlagen: {e}");
None
}
}
}
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_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 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 mut nav_detected = false;
let mut before: Option<AXSnapshot> = None;
for (selector, href) in &candidates {
let Some(baseline) = snapshot(page, "before_spa_nav").await else {
return Ok(None);
};
trace.steps.push(JourneyStep {
action: "baseline".to_string(),
target: Some(selector.clone()),
focus: baseline.focus.selector.clone(),
result: Some(format!(
"title:{:?}, h1:{:?}",
baseline.document_title,
top_headings(&baseline.tree)
.first()
.cloned()
.unwrap_or_default()
)),
snapshot_label: Some("before_spa_nav".to_string()),
});
before = Some(baseline);
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()),
});
let _ = stability::settle_after_action(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);
}
let _ = stability::settle_after_action(page).await;
let (Some(before), Some(after)) = (before, snapshot(page, "after_spa_nav").await) else {
return Ok(Some((trace, Vec::new())));
};
let diff = AXTreeDiff::between(&before, &after);
let Signals {
title_changed,
heading_changed,
focus_moved,
} = signals(&before, &after, &diff);
let title_before = before.document_title.clone();
trace.steps.push(JourneyStep {
action: "check_spa_announcement".to_string(),
target: None,
focus: after.focus.selector.clone(),
result: Some(format!(
"title_changed:{title_changed}, heading_changed:{heading_changed}, \
focus_moved:{focus_moved}, url_changed:{}",
diff.url_changed.is_some()
)),
snapshot_label: Some("after_spa_nav".to_string()),
});
let mut findings = Vec::new();
if !title_changed && !heading_changed && !focus_moved {
findings.push(InteractiveFinding::new(
"SpaNavigation",
InteractiveFindingKind::SpaNoAnnouncementSignal,
None,
Severity::High,
journey_name.clone(),
Some("before_spa_nav".to_string()),
Some("after_spa_nav".to_string()),
InteractiveFindingValues {
title_before: Some(title_before.clone()),
..Default::default()
},
));
} else if !title_changed {
findings.push(InteractiveFinding::new(
"SpaNavigation",
InteractiveFindingKind::SpaTitleUnchanged,
None,
Severity::Medium,
journey_name.clone(),
Some("before_spa_nav".to_string()),
Some("after_spa_nav".to_string()),
InteractiveFindingValues {
title_before: Some(title_before.clone()),
..Default::default()
},
));
} else if !focus_moved {
findings.push(InteractiveFinding::new(
"SpaNavigation",
InteractiveFindingKind::SpaFocusNotMoved,
None,
Severity::Medium,
journey_name.clone(),
Some("before_spa_nav".to_string()),
Some("after_spa_nav".to_string()),
InteractiveFindingValues::default(),
));
}
Ok(Some((trace, findings)))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::accessibility::{AXNode, AXProperty, AXValue, FocusSnapshot};
fn heading(ax_id: &str, backend: i64, level: i64, name: &str) -> AXNode {
AXNode {
node_id: ax_id.to_string(),
ignored: false,
ignored_reasons: Vec::new(),
role: Some("heading".to_string()),
name: Some(name.to_string()),
name_source: None,
description: None,
value: None,
properties: vec![AXProperty {
name: "level".to_string(),
value: AXValue::Int(level),
}],
child_ids: Vec::new(),
parent_id: None,
backend_dom_node_id: Some(backend),
}
}
fn snap(title: &str, nodes: Vec<AXNode>, focus: Option<i64>) -> AXSnapshot {
AXSnapshot::new(
"s",
"https://x/a",
title,
0,
AXTree::from_nodes(nodes),
FocusSnapshot {
active_backend_node_id: focus,
..Default::default()
},
)
}
#[test]
fn ueberschriften_erster_ebene_werden_gelesen() {
let tree = AXTree::from_nodes(vec![
heading("1", 1, 1, "Startseite"),
heading("2", 2, 2, "Abschnitt"),
heading("3", 3, 1, " "),
]);
assert_eq!(top_headings(&tree), vec!["Startseite".to_string()]);
}
#[test]
fn alle_drei_signale_fehlen() {
let before = snap("Titel", vec![heading("1", 1, 1, "A")], Some(9));
let after = snap("Titel", vec![heading("1", 1, 1, "A")], Some(9));
let diff = AXTreeDiff::between(&before, &after);
assert_eq!(
signals(&before, &after, &diff),
Signals {
title_changed: false,
heading_changed: false,
focus_moved: false
}
);
}
#[test]
fn titel_ueberschrift_und_fokus_werden_einzeln_erkannt() {
let before = snap("Alt", vec![heading("1", 1, 1, "A")], Some(9));
let after = snap("Neu", vec![heading("1", 1, 1, "B")], Some(10));
let diff = AXTreeDiff::between(&before, &after);
assert_eq!(
signals(&before, &after, &diff),
Signals {
title_changed: true,
heading_changed: true,
focus_moved: true
}
);
}
#[test]
fn leerer_titel_zaehlt_nicht_als_wechsel() {
let before = snap("Alt", vec![], None);
let after = snap(" ", vec![], None);
let diff = AXTreeDiff::between(&before, &after);
assert!(!signals(&before, &after, &diff).title_changed);
}
#[test]
fn fehlender_fokus_danach_ist_keine_bewegung() {
let before = snap("T", vec![], Some(5));
let after = snap("T", vec![], None);
let diff = AXTreeDiff::between(&before, &after);
assert!(!signals(&before, &after, &diff).focus_moved);
}
}