use chromiumoxide::cdp::js_protocol::runtime::EvaluateParams;
use chromiumoxide::Page;
use crate::audit::normalized::{
InteractiveFinding, InteractiveFindingKind, InteractiveFindingValues, JourneyStep, JourneyTrace,
};
use crate::error::Result;
use crate::interaction::{focus, keyboard, pointer, stability};
use crate::patterns::JourneyCandidate;
use crate::taxonomy::Severity;
const COLLECT_TABS_JS: &str = r#"
(function() {
var tabs = Array.from(document.querySelectorAll('[role="tab"]'));
return tabs.map(function(t) { return t.getAttribute('aria-selected'); });
})()
"#;
async fn collect_tab_states(page: &Page) -> Option<Vec<Option<String>>> {
let params = EvaluateParams::builder()
.expression(COLLECT_TABS_JS.to_string())
.return_by_value(true)
.build()
.ok()?;
let result = page.execute(params).await.ok()?;
let value = result.result.result.value?;
let arr = value.as_array()?;
Some(
arr.iter()
.map(|v| {
if v.is_null() {
None
} else {
v.as_str().map(|s| s.to_string())
}
})
.collect(),
)
}
pub async fn test(
page: &Page,
candidate: &JourneyCandidate,
index: usize,
) -> Result<(JourneyTrace, Vec<InteractiveFinding>)> {
let journey_name = format!("tabs_{index}");
let mut trace = JourneyTrace {
journey: journey_name.clone(),
steps: Vec::new(),
};
let mut findings: Vec<InteractiveFinding> = Vec::new();
let trigger_id = match candidate.trigger_backend_id {
Some(id) => id,
None => return Ok((trace, findings)),
};
if let Err(e) = pointer::synthetic_click_backend(page, trigger_id).await {
tracing::warn!("tabs: 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_first_tab_click".to_string()),
});
stability::settle(page).await?;
let states_after_click = collect_tab_states(page).await;
if let Err(e) = keyboard::press_arrow(page, "Right").await {
tracing::warn!("tabs: ArrowRight failed: {e}");
return Ok((trace, findings));
}
trace.steps.push(JourneyStep {
action: "arrow_right".to_string(),
target: None,
focus: None,
result: None,
snapshot_label: None,
});
stability::settle(page).await?;
let states_after_arrow = collect_tab_states(page).await;
let focus_snap = focus::capture_focus(page).await?;
let selection_moved = match (&states_after_click, &states_after_arrow) {
(Some(before), Some(after)) => before != after,
_ => true, };
trace.steps.push(JourneyStep {
action: "check_selection".to_string(),
target: None,
focus: focus_snap.selector.clone(),
result: Some(if selection_moved {
"selection_moved".to_string()
} else {
"selection_unchanged".to_string()
}),
snapshot_label: None,
});
if !selection_moved {
findings.push(InteractiveFinding::new(
"TabsJourney",
InteractiveFindingKind::TabsSelectionNotMoved,
None,
Severity::High,
journey_name.clone(),
Some("after_first_tab_click".to_string()),
None,
InteractiveFindingValues::default(),
));
}
let focus_on_tab = focus_snap
.selector
.as_deref()
.map(|s| {
!s.to_lowercase().contains("body")
})
.unwrap_or(false);
if !focus_on_tab {
findings.push(InteractiveFinding::new(
"TabsJourney",
InteractiveFindingKind::TabsFocusNotOnTab,
None,
Severity::Medium,
journey_name,
None,
None,
InteractiveFindingValues::default(),
));
}
Ok((trace, findings))
}