use chromiumoxide::cdp::js_protocol::runtime::EvaluateParams;
use chromiumoxide::Page;
use super::{eval_bool, eval_string};
use crate::audit::normalized::{
InteractiveFinding, InteractiveFindingKind, InteractiveFindingValues, JourneyStep, JourneyTrace,
};
use crate::error::Result;
use crate::interaction::{focus, pointer, stability};
use crate::patterns::JourneyCandidate;
use crate::taxonomy::Severity;
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 initial_href = eval_string(page, "window.location.href").await;
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 {
findings.push(InteractiveFinding::new(
"FormError",
InteractiveFindingKind::FormErrorLiveRegionLateInsertion,
None,
Severity::Low,
journey_name.clone(),
Some("before_submit".to_string()),
Some("after_submit_click".to_string()),
InteractiveFindingValues::default(),
));
}
if !new_live && !new_invalid {
let url_changed = match (
&initial_href,
eval_string(page, "window.location.href").await,
) {
(Some(before), Some(after)) => before != &after,
_ => false,
};
if url_changed {
return Ok((trace, findings));
}
findings.push(InteractiveFinding::new(
"FormError",
InteractiveFindingKind::FormErrorSilentFailure,
None,
Severity::High,
journey_name.clone(),
Some("before_submit".to_string()),
Some("after_submit_click".to_string()),
InteractiveFindingValues::default(),
));
return Ok((trace, findings));
}
if new_invalid && !new_live {
findings.push(InteractiveFinding::new(
"FormError",
InteractiveFindingKind::FormErrorInvalidWithoutLiveRegion,
None,
Severity::High,
journey_name.clone(),
Some("before_submit".to_string()),
Some("after_submit_click".to_string()),
InteractiveFindingValues::default(),
));
}
if new_invalid && linked_after < invalid_after {
let unlinked = invalid_after - linked_after;
findings.push(InteractiveFinding::new(
"FormError",
InteractiveFindingKind::FormErrorUnlinkedFields,
None,
Severity::Medium,
journey_name.clone(),
Some("before_submit".to_string()),
Some("after_submit_click".to_string()),
InteractiveFindingValues {
count: Some(unlinked as u32),
..Default::default()
},
));
}
if (new_live || new_invalid) && focus_on_body {
findings.push(InteractiveFinding::new(
"FormError",
InteractiveFindingKind::FormErrorFocusNotManaged,
None,
Severity::Medium,
journey_name.clone(),
Some("before_submit".to_string()),
Some("after_submit_click".to_string()),
InteractiveFindingValues::default(),
));
}
Ok((trace, findings))
}