use chromiumoxide::cdp::browser_protocol::dom::{BackendNodeId, ResolveNodeParams};
use chromiumoxide::cdp::js_protocol::runtime::CallFunctionOnParams;
use chromiumoxide::Page;
use crate::audit::normalized::{
InteractiveFinding, InteractiveFindingKind, InteractiveFindingValues, JourneyStep, JourneyTrace,
};
use crate::error::Result;
use crate::interaction::{keyboard, stability};
use crate::patterns::JourneyCandidate;
use crate::taxonomy::Severity;
async fn call_on_backend_string(
page: &Page,
backend_node_id: i64,
js_fn_body: &str,
) -> Option<String> {
let resolve = ResolveNodeParams::builder()
.backend_node_id(BackendNodeId::new(backend_node_id))
.build();
let resolved = page.execute(resolve).await.ok()?;
let object_id = resolved.result.object.object_id.clone()?;
let call = CallFunctionOnParams::builder()
.function_declaration(js_fn_body.to_string())
.object_id(object_id)
.return_by_value(true)
.await_promise(false)
.build()
.ok()?;
let result = page.execute(call).await.ok()?;
result.result.result.value?.as_str().map(|s| s.to_string())
}
async fn call_on_backend_bool(page: &Page, backend_node_id: i64, js_fn_body: &str) -> Option<bool> {
let resolve = ResolveNodeParams::builder()
.backend_node_id(BackendNodeId::new(backend_node_id))
.build();
let resolved = page.execute(resolve).await.ok()?;
let object_id = resolved.result.object.object_id.clone()?;
let call = CallFunctionOnParams::builder()
.function_declaration(js_fn_body.to_string())
.object_id(object_id)
.return_by_value(true)
.await_promise(false)
.build()
.ok()?;
let result = page.execute(call).await.ok()?;
result.result.result.value?.as_bool()
}
async fn read_value(page: &Page, backend_node_id: i64) -> Option<String> {
call_on_backend_string(
page,
backend_node_id,
"function() { return (this.value !== undefined && this.value !== null) ? String(this.value) : ''; }",
)
.await
}
async fn read_valuenow(page: &Page, backend_node_id: i64) -> Option<String> {
call_on_backend_string(
page,
backend_node_id,
"function() { return this.getAttribute('aria-valuenow'); }",
)
.await
}
async fn is_native_input(page: &Page, backend_node_id: i64) -> bool {
call_on_backend_bool(
page,
backend_node_id,
"function() { return !!(this.tagName && this.tagName.toLowerCase() === 'input'); }",
)
.await
.unwrap_or(false)
}
async fn focus_and_confirm(page: &Page, backend_node_id: i64) -> bool {
call_on_backend_bool(
page,
backend_node_id,
"function() { this.focus(); return document.activeElement === this; }",
)
.await
.unwrap_or(false)
}
pub async fn test(
page: &Page,
candidate: &JourneyCandidate,
index: usize,
) -> Result<(JourneyTrace, Vec<InteractiveFinding>)> {
let journey_name = format!("quantity_stepper_{index}");
let mut trace = JourneyTrace {
journey: journey_name.clone(),
steps: Vec::new(),
};
let mut findings: Vec<InteractiveFinding> = Vec::new();
let Some(stepper_id) = candidate.trigger_backend_id else {
return Ok((trace, findings));
};
let value_before = read_value(page, stepper_id).await;
let valuenow_before = read_valuenow(page, stepper_id).await;
trace.steps.push(JourneyStep {
action: "check_baseline".to_string(),
target: Some(format!("backend_node:{stepper_id}")),
focus: None,
result: Some(format!(
"value:{value_before:?}, valuenow:{valuenow_before:?}"
)),
snapshot_label: Some("before_arrow_up".to_string()),
});
let focused = focus_and_confirm(page, stepper_id).await;
trace.steps.push(JourneyStep {
action: "focus".to_string(),
target: Some(format!("backend_node:{stepper_id}")),
focus: None,
result: Some(format!("focused:{focused}")),
snapshot_label: Some("after_focus".to_string()),
});
if !focused {
findings.push(InteractiveFinding::new(
"QuantityStepper",
InteractiveFindingKind::QuantityStepperKeyboardInoperable,
None,
Severity::High,
journey_name.clone(),
Some("before_arrow_up".to_string()),
Some("after_focus".to_string()),
InteractiveFindingValues::default(),
));
return Ok((trace, findings));
}
keyboard::press(page, "ArrowUp").await?;
let _ = stability::settle_after_action(page).await;
let value_after = read_value(page, stepper_id).await;
let valuenow_after = read_valuenow(page, stepper_id).await;
let native_input = is_native_input(page, stepper_id).await;
trace.steps.push(JourneyStep {
action: "arrow_up".to_string(),
target: Some(format!("backend_node:{stepper_id}")),
focus: None,
result: Some(format!(
"value:{value_after:?}, valuenow:{valuenow_after:?}, native_input:{native_input}"
)),
snapshot_label: Some("after_arrow_up".to_string()),
});
let value_changed = value_after.is_some() && value_after != value_before;
if !value_changed {
findings.push(InteractiveFinding::new(
"QuantityStepper",
InteractiveFindingKind::QuantityStepperKeyboardInoperable,
None,
Severity::High,
journey_name.clone(),
Some("before_arrow_up".to_string()),
Some("after_arrow_up".to_string()),
InteractiveFindingValues::default(),
));
return Ok((trace, findings));
}
if !native_input && (valuenow_after.is_none() || valuenow_after == valuenow_before) {
findings.push(InteractiveFinding::new(
"QuantityStepper",
InteractiveFindingKind::QuantityStepperValueNotExposed,
None,
Severity::Medium,
journey_name.clone(),
Some("before_arrow_up".to_string()),
Some("after_arrow_up".to_string()),
InteractiveFindingValues::default(),
));
}
Ok((trace, findings))
}