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) -> Option<bool> {
call_on_backend_bool(
page,
backend_node_id,
"function() { return !!(this.tagName && this.tagName.toLowerCase() === 'input'); }",
)
.await
}
async fn focus_and_confirm(page: &Page, backend_node_id: i64) -> Option<bool> {
call_on_backend_bool(
page,
backend_node_id,
"function() { this.focus(); return document.activeElement === this; }",
)
.await
}
fn not_observable_step(action: &str, stepper_id: i64, label: &str) -> JourneyStep {
JourneyStep {
action: action.to_string(),
target: Some(format!("backend_node:{stepper_id}")),
focus: None,
result: Some("not_observable".to_string()),
snapshot_label: Some(label.to_string()),
}
}
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 Some(focused) = focus_and_confirm(page, stepper_id).await else {
trace
.steps
.push(not_observable_step("focus", stepper_id, "after_focus"));
return Ok((trace, findings));
};
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.map_or_else(|| "not_observable".to_string(), |b| b.to_string())
)),
snapshot_label: Some("after_arrow_up".to_string()),
});
if value_before.is_none() || value_after.is_none() {
trace.steps.push(not_observable_step(
"compare_value",
stepper_id,
"after_arrow_up",
));
return Ok((trace, findings));
}
let value_changed = 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 == Some(false)
&& (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))
}