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, pointer, stability};
use crate::patterns::JourneyCandidate;
use crate::taxonomy::Severity;
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 check_error_state(page: &Page) -> (bool, usize, usize) {
let live_present = eval_bool(
page,
"document.querySelector('[role=\"alert\"],[aria-live]') !== null",
)
.await
.unwrap_or(false);
let invalid_count = eval_int(
page,
"document.querySelectorAll('[aria-invalid=\"true\"]').length",
)
.await
.unwrap_or(0) as usize;
let linked_count = eval_int(
page,
r#"(function() {
var fields = document.querySelectorAll(
'input[aria-invalid="true"], textarea[aria-invalid="true"], select[aria-invalid="true"]'
);
var linked = 0;
Array.from(fields).forEach(function(f) {
if (f.getAttribute('aria-describedby') || f.getAttribute('aria-errormessage')) {
linked++;
}
});
return linked;
})()"#,
)
.await
.unwrap_or(0) as usize;
(live_present, invalid_count, linked_count)
}
pub async fn test(
page: &Page,
candidate: &JourneyCandidate,
index: usize,
) -> Result<(JourneyTrace, Vec<InteractiveFinding>)> {
let journey_name = format!("form_error_{index}");
let mut trace = JourneyTrace {
journey: journey_name.clone(),
steps: Vec::new(),
};
let mut findings: Vec<InteractiveFinding> = Vec::new();
let (live_before, invalid_before, _linked_before) = check_error_state(page).await;
trace.steps.push(JourneyStep {
action: "check_baseline_errors".to_string(),
target: None,
focus: None,
result: Some(format!(
"live_region:{}, aria_invalid:{}",
live_before, invalid_before
)),
snapshot_label: Some("before_submit".to_string()),
});
let Some(trigger_id) = candidate.trigger_backend_id else {
return Ok((trace, findings));
};
if let Err(e) = pointer::synthetic_click_backend(page, trigger_id).await {
tracing::warn!("form_error: 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_submit_click".to_string()),
});
stability::settle(page).await?;
let (live_after, invalid_after, linked_after) = check_error_state(page).await;
let new_live = live_after && !live_before;
let new_invalid = invalid_after > invalid_before;
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(false);
trace.steps.push(JourneyStep {
action: "check_error_state".to_string(),
target: None,
focus: focus_sel,
result: Some(format!(
"live_region:{new_live}, aria_invalid:{new_invalid}, linked:{linked_after}, focus_on_body:{focus_on_body}"
)),
snapshot_label: Some("after_submit_click".to_string()),
});
if !new_live && !new_invalid {
let url_changed = eval_bool(
page,
&format!(
"window.location.href !== {:?}",
"#PLACEHOLDER"
),
)
.await
.unwrap_or(false);
let _ = url_changed;
findings.push(InteractiveFinding {
category: "FormError".to_string(),
maps_to_finding: None,
severity: Severity::High,
journey: journey_name.clone(),
before_snapshot_label: Some("before_submit".to_string()),
after_snapshot_label: Some("after_submit_click".to_string()),
message: "Formular-Fehler werden nicht über eine Live-Region (role=\"alert\" oder \
aria-live) angekündigt und aria-invalid wird nicht gesetzt. \
Screenreader-Nutzer erhalten keine Rückmeldung, wenn das Pflichtfeld \
leer geblieben ist."
.to_string(),
fix_suggestion: Some(
"Fehlermeldungen in einem role=\"alert\"-Element ausgeben und \
aria-invalid=\"true\" auf jedem fehlerhaften Feld setzen."
.to_string(),
),
});
return Ok((trace, findings));
}
if new_invalid && !new_live {
findings.push(InteractiveFinding {
category: "FormError".to_string(),
maps_to_finding: None,
severity: Severity::High,
journey: journey_name.clone(),
before_snapshot_label: Some("before_submit".to_string()),
after_snapshot_label: Some("after_submit_click".to_string()),
message: "aria-invalid wird nach dem Absenden gesetzt, aber es existiert keine \
Live-Region (role=\"alert\" oder aria-live), die den Fehler ankündigt. \
Screenreader-Nutzer bemerken den Fehlerzustand erst, wenn sie explizit \
zurück zum Feld navigieren."
.to_string(),
fix_suggestion: Some(
"Einen role=\"alert\"-Container ergänzen, in dem die Fehlermeldung \
nach dem Absenden ausgegeben wird."
.to_string(),
),
});
}
if new_invalid && linked_after < invalid_after {
let unlinked = invalid_after - linked_after;
findings.push(InteractiveFinding {
category: "FormError".to_string(),
maps_to_finding: None,
severity: Severity::Medium,
journey: journey_name.clone(),
before_snapshot_label: Some("before_submit".to_string()),
after_snapshot_label: Some("after_submit_click".to_string()),
message: format!(
"{unlinked} Feld(er) mit aria-invalid=\"true\" sind nicht über \
aria-describedby oder aria-errormessage mit ihrer Fehlermeldung verknüpft. \
Screenreader-Nutzer hören den Fehler, können ihn aber nicht dem Feld \
zuordnen."
),
fix_suggestion: Some(
"aria-describedby=\"id-der-fehlermeldung\" auf jedem Feld mit \
aria-invalid=\"true\" ergänzen."
.to_string(),
),
});
}
Ok((trace, findings))
}
#[cfg(test)]
mod tests {
#[test]
fn module_exists() {}
}