use chromiumoxide::Page;
use crate::accessibility::{AXSnapshot, AXTreeDiff};
use crate::audit::normalized::{
InteractiveFinding, InteractiveFindingKind, InteractiveFindingValues, JourneyStep, JourneyTrace,
};
use crate::error::Result;
use crate::interaction::{keyboard, pointer, stability};
use crate::patterns::JourneyCandidate;
use crate::taxonomy::Severity;
const TAB_STEPS: usize = 3;
fn is_dialog(role: Option<&str>) -> bool {
matches!(role, Some("dialog") | Some("alertdialog"))
}
fn opened_dialog(after: &AXSnapshot, diff: &AXTreeDiff) -> Option<i64> {
let candidates: Vec<&crate::accessibility::AXNode> = after
.tree
.iter()
.filter(|n| is_dialog(n.role.as_deref()))
.collect();
candidates
.iter()
.find(|n| diff.added.contains(&n.node_id))
.or_else(|| {
candidates.iter().find(|n| {
n.backend_dom_node_id.is_some_and(|bid| {
diff.property_change_for(bid, "modal").is_some()
|| diff.property_change_for(bid, "hidden").is_some()
})
})
})
.and_then(|n| n.backend_dom_node_id)
}
fn focus_within(snapshot: &AXSnapshot, dialog: i64) -> Option<bool> {
let focused = snapshot.focus.active_backend_node_id?;
Some(snapshot.tree.is_within(focused, dialog))
}
fn still_open(snapshot: &AXSnapshot, dialog: i64) -> bool {
match snapshot.tree.node_by_backend_id(dialog) {
Some(node) => !node.get_property_bool("hidden").unwrap_or(false),
None => false,
}
}
async fn snapshot(page: &Page, label: &str) -> Option<AXSnapshot> {
match AXSnapshot::capture(page, label, 0).await {
Ok(snapshot) => Some(snapshot),
Err(e) => {
tracing::warn!("modal: Aufnahme '{label}' fehlgeschlagen: {e}");
None
}
}
}
pub async fn test(
page: &Page,
candidate: &JourneyCandidate,
index: usize,
) -> Result<(JourneyTrace, Vec<InteractiveFinding>)> {
let journey_name = format!("modal_{index}");
let mut trace = JourneyTrace {
journey: journey_name.clone(),
steps: Vec::new(),
};
let mut findings: Vec<InteractiveFinding> = Vec::new();
let finding = |kind: InteractiveFindingKind,
category: &str,
severity: Severity,
after: Option<String>,
findings: &mut Vec<InteractiveFinding>| {
findings.push(InteractiveFinding::new(
category,
kind,
None,
severity,
journey_name.clone(),
None,
after,
InteractiveFindingValues::default(),
));
};
let trigger_id = match candidate.trigger_backend_id {
Some(id) => id,
None => return Ok((trace, findings)),
};
let Some(before) = snapshot(page, "initial").await else {
return Ok((trace, findings));
};
if before.tree.node_by_backend_id(trigger_id).is_none() {
trace.steps.push(JourneyStep {
action: "initial_state".to_string(),
target: Some(format!("backend_node:{trigger_id}")),
focus: None,
result: Some("trigger_not_in_tree".to_string()),
snapshot_label: Some("initial".to_string()),
});
return Ok((trace, findings));
}
if let Err(e) = pointer::synthetic_click_backend(page, trigger_id).await {
tracing::warn!("modal: click on backend node {trigger_id} failed: {e}");
return Ok((trace, findings));
}
trace.steps.push(JourneyStep {
action: "synthetic_click".to_string(),
target: Some(format!("backend_node:{trigger_id}")),
focus: None,
result: None,
snapshot_label: Some("after_open_click".to_string()),
});
let _ = stability::settle_after_action(page).await;
let Some(after_open) = snapshot(page, "after_open_click").await else {
return Ok((trace, findings));
};
let open_diff = AXTreeDiff::between(&before, &after_open);
let dialog = opened_dialog(&after_open, &open_diff);
trace.steps.push(JourneyStep {
action: "check_dialog_opened".to_string(),
target: dialog.map(|d| format!("backend_node:{d}")),
focus: None,
result: Some(match dialog {
Some(_) => "dialog_opened".to_string(),
None => "no_dialog_observed".to_string(),
}),
snapshot_label: Some("after_open_click".to_string()),
});
let Some(dialog) = dialog else {
finding(
InteractiveFindingKind::ModalNotOpened,
"StateTransition",
Severity::Medium,
Some("after_open_click".to_string()),
&mut findings,
);
return Ok((trace, findings));
};
let modal = after_open
.tree
.node_by_backend_id(dialog)
.and_then(|n| n.get_property_bool("modal"))
.unwrap_or(false);
trace.steps.push(JourneyStep {
action: "check_dialog_modal".to_string(),
target: Some(format!("backend_node:{dialog}")),
focus: None,
result: Some(if modal { "modal" } else { "not_modal" }.to_string()),
snapshot_label: Some("after_open_click".to_string()),
});
let entered = focus_within(&after_open, dialog);
trace.steps.push(JourneyStep {
action: "check_focus_in_dialog".to_string(),
target: Some(format!("backend_node:{dialog}")),
focus: after_open.focus.selector.clone(),
result: Some(
match entered {
Some(true) => "focus_inside_dialog",
Some(false) => "focus_not_in_dialog",
None => "no_focus_captured",
}
.to_string(),
),
snapshot_label: Some("after_open_click".to_string()),
});
if modal && entered == Some(false) {
finding(
InteractiveFindingKind::FocusTrapNotEntered,
"FocusTrap",
Severity::High,
Some("after_open_click".to_string()),
&mut findings,
);
}
if entered != Some(true) {
trace.steps.push(JourneyStep {
action: "check_focus_in_dialog".to_string(),
target: Some(format!("backend_node:{dialog}")),
focus: None,
result: Some("keyboard_steps_not_applicable".to_string()),
snapshot_label: Some("after_open_click".to_string()),
});
return Ok((trace, findings));
}
let mut escaped = false;
for step in 1..=TAB_STEPS {
if let Err(e) = keyboard::press_tab(page).await {
tracing::warn!("modal: Tab {step} fehlgeschlagen: {e}");
break;
}
let _ = stability::settle_after_action(page).await;
let label = format!("after_tab_{step}");
let Some(shot) = snapshot(page, &label).await else {
break;
};
let inside = focus_within(&shot, dialog);
trace.steps.push(JourneyStep {
action: "tab".to_string(),
target: None,
focus: shot.focus.selector.clone(),
result: Some(
match inside {
Some(true) => "focus_in_dialog",
Some(false) => "focus_escaped",
None => "no_focus_captured",
}
.to_string(),
),
snapshot_label: Some(label),
});
if inside == Some(false) {
escaped = true;
break;
}
}
if modal && escaped {
finding(
InteractiveFindingKind::FocusTrapEscaped,
"FocusTrap",
Severity::High,
None,
&mut findings,
);
}
if !modal && !escaped && entered == Some(true) {
finding(
InteractiveFindingKind::FocusTrapBackgroundNotHidden,
"FocusTrap",
Severity::High,
Some("after_open_click".to_string()),
&mut findings,
);
}
if let Err(e) = keyboard::press_escape(page).await {
tracing::warn!("modal: Escape fehlgeschlagen: {e}");
return Ok((trace, findings));
}
trace.steps.push(JourneyStep {
action: "escape".to_string(),
target: None,
focus: None,
result: None,
snapshot_label: Some("after_escape".to_string()),
});
let _ = stability::settle_after_action(page).await;
let Some(after_escape) = snapshot(page, "after_escape").await else {
return Ok((trace, findings));
};
let open_after_escape = still_open(&after_escape, dialog);
trace.steps.push(JourneyStep {
action: "check_dialog_closed".to_string(),
target: Some(format!("backend_node:{dialog}")),
focus: None,
result: Some(
if open_after_escape {
"dialog_still_open"
} else {
"dialog_closed"
}
.to_string(),
),
snapshot_label: Some("after_escape".to_string()),
});
if open_after_escape {
finding(
InteractiveFindingKind::FocusTrapEscapeNotClosing,
"FocusTrap",
Severity::High,
Some("after_escape".to_string()),
&mut findings,
);
return Ok((trace, findings));
}
let restored = after_escape.focus.active_backend_node_id;
trace.steps.push(JourneyStep {
action: "check_focus_restored".to_string(),
target: Some(format!("backend_node:{trigger_id}")),
focus: after_escape.focus.selector.clone(),
result: Some(
match restored {
Some(id) if id == trigger_id => "focus_on_trigger",
Some(_) => "focus_elsewhere",
None => "focus_lost",
}
.to_string(),
),
snapshot_label: Some("after_escape".to_string()),
});
let had_focus_before =
before.focus.active_backend_node_id.is_some() || before.focus.selector.is_some();
let focus_lost = restored.is_none() && after_escape.focus.selector.is_none();
if !had_focus_before {
trace.steps.push(JourneyStep {
action: "check_focus_restored".to_string(),
target: None,
focus: None,
result: Some("no_focus_before_open".to_string()),
snapshot_label: Some("after_escape".to_string()),
});
} else if focus_lost {
finding(
InteractiveFindingKind::FocusRestorationLostToBody,
"FocusRestoration",
Severity::Medium,
Some("after_escape".to_string()),
&mut findings,
);
}
Ok((trace, findings))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::accessibility::{AXNode, AXProperty, AXTree, AXValue, FocusSnapshot};
fn node(ax_id: &str, backend: i64, role: &str, parent: Option<&str>) -> AXNode {
AXNode {
node_id: ax_id.to_string(),
ignored: false,
ignored_reasons: Vec::new(),
role: Some(role.to_string()),
name: None,
name_source: None,
description: None,
value: None,
properties: Vec::new(),
child_ids: Vec::new(),
parent_id: parent.map(String::from),
backend_dom_node_id: Some(backend),
}
}
fn with_bool(mut n: AXNode, name: &str, value: bool) -> AXNode {
n.properties.push(AXProperty {
name: name.to_string(),
value: AXValue::Bool(value),
});
n
}
fn snap(nodes: Vec<AXNode>, focus: Option<i64>) -> AXSnapshot {
AXSnapshot::new(
"s",
"https://x",
"T",
0,
AXTree::from_nodes(nodes),
FocusSnapshot {
active_backend_node_id: focus,
..Default::default()
},
)
}
#[test]
fn der_neu_erschienene_dialog_wird_gewaehlt() {
let before = snap(vec![node("old", 1, "dialog", None)], None);
let after = snap(
vec![
node("old", 1, "dialog", None),
node("new", 2, "dialog", None),
],
None,
);
let diff = AXTreeDiff::between(&before, &after);
assert_eq!(opened_dialog(&after, &diff), Some(2));
}
#[test]
fn sichtbar_gewordener_dialog_wird_erkannt() {
let before = snap(
vec![with_bool(node("d", 5, "dialog", None), "hidden", true)],
None,
);
let after = snap(
vec![with_bool(node("d", 5, "dialog", None), "hidden", false)],
None,
);
let diff = AXTreeDiff::between(&before, &after);
assert_eq!(opened_dialog(&after, &diff), Some(5));
}
#[test]
fn unveraenderter_dialog_gilt_nicht_als_geoeffnet() {
let before = snap(vec![node("d", 5, "dialog", None)], None);
let after = snap(vec![node("d", 5, "dialog", None)], None);
let diff = AXTreeDiff::between(&before, &after);
assert_eq!(opened_dialog(&after, &diff), None);
}
#[test]
fn fokus_im_dialog_wird_ueber_die_elternkette_entschieden() {
let tree = vec![
node("d", 10, "dialog", None),
node("inner", 11, "generic", Some("d")),
node("btn", 12, "button", Some("inner")),
node("outside", 20, "button", None),
];
assert_eq!(focus_within(&snap(tree.clone(), Some(12)), 10), Some(true));
assert_eq!(focus_within(&snap(tree.clone(), Some(10)), 10), Some(true));
assert_eq!(focus_within(&snap(tree.clone(), Some(20)), 10), Some(false));
}
#[test]
fn fehlender_fokus_ist_keine_aussage() {
let tree = vec![node("d", 10, "dialog", None)];
assert_eq!(focus_within(&snap(tree, None), 10), None);
}
#[test]
fn geschlossen_heisst_verschwunden_oder_versteckt() {
let visible = snap(vec![node("d", 10, "dialog", None)], None);
assert!(still_open(&visible, 10));
let hidden = snap(
vec![with_bool(node("d", 10, "dialog", None), "hidden", true)],
None,
);
assert!(!still_open(&hidden, 10));
let gone = snap(vec![node("other", 99, "button", None)], None);
assert!(!still_open(&gone, 10));
}
}