use chromiumoxide::cdp::browser_protocol::dom::{BackendNodeId, ResolveNodeParams};
use chromiumoxide::cdp::js_protocol::runtime::{
CallArgument, CallFunctionOnParams, RemoteObjectId,
};
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;
enum Target {
Found(RemoteObjectId),
Missing,
NoFragment,
}
async fn focus_and_resolve(page: &Page, trigger_id: i64) -> Option<(RemoteObjectId, Target)> {
let resolve = ResolveNodeParams::builder()
.backend_node_id(BackendNodeId::new(trigger_id))
.build();
let resolved = page.execute(resolve).await.ok()?;
let trigger = resolved.result.object.object_id.clone()?;
let call = CallFunctionOnParams::builder()
.function_declaration(
"function() {
this.focus();
const href = this.getAttribute('href') || '';
if (!href.startsWith('#') || href.length < 2) return 'no_fragment';
let id = href.slice(1);
try { id = decodeURIComponent(id); } catch (e) {}
return document.getElementById(id) || document.getElementsByName(id)[0] || 'missing';
}"
.to_string(),
)
.object_id(trigger.clone())
.return_by_value(false)
.build()
.ok()?;
let result = page.execute(call).await.ok()?.result.result;
let target = match (
result.object_id,
result.value.as_ref().and_then(|v| v.as_str()),
) {
(Some(id), _) => Target::Found(id),
(None, Some("missing")) => Target::Missing,
(None, Some("no_fragment")) => Target::NoFragment,
_ => return None,
};
Some((trigger, target))
}
async fn focus_relation(
page: &Page,
target: &RemoteObjectId,
trigger: &RemoteObjectId,
) -> Option<String> {
let call = CallFunctionOnParams::builder()
.function_declaration(
"function(trigger) {
const a = document.activeElement;
if (!a || a === document.body || a === document.documentElement) return 'none';
if (a === this || this.contains(a)) return 'inside';
if (a === trigger) return 'trigger';
return (this.compareDocumentPosition(a) & Node.DOCUMENT_POSITION_FOLLOWING)
? 'after' : 'before';
}"
.to_string(),
)
.object_id(target.clone())
.argument(CallArgument::builder().object_id(trigger.clone()).build())
.return_by_value(true)
.build()
.ok()?;
let result = page.execute(call).await.ok()?;
result.result.result.value?.as_str().map(|s| s.to_string())
}
fn reached(relation: &str) -> bool {
matches!(relation, "inside" | "after")
}
pub async fn test(
page: &Page,
candidate: &JourneyCandidate,
index: usize,
) -> Result<(JourneyTrace, Vec<InteractiveFinding>)> {
let journey_name = format!("skip_link_{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)),
};
let Some((trigger, target)) = focus_and_resolve(page, trigger_id).await else {
trace.steps.push(JourneyStep {
action: "resolve_target".to_string(),
target: Some(format!("backend_node:{trigger_id}")),
focus: None,
result: Some("not_observable".to_string()),
snapshot_label: None,
});
return Ok((trace, findings));
};
trace.steps.push(JourneyStep {
action: "resolve_target".to_string(),
target: Some(format!("backend_node:{trigger_id}")),
focus: None,
result: Some(
match &target {
Target::Found(_) => "target_resolved",
Target::Missing => "target_missing",
Target::NoFragment => "no_fragment",
}
.to_string(),
),
snapshot_label: None,
});
if let Err(e) = pointer::synthetic_click_backend(page, trigger_id).await {
tracing::warn!("skip_link: 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_skip_click".to_string()),
});
let _ = stability::settle_after_action(page).await;
let moved = match target {
Target::Found(target) => {
let Some(relation) = focus_relation(page, &target, &trigger).await else {
trace
.steps
.push(check_step("check_focus", None, "not_observable"));
return Ok((trace, findings));
};
if reached(&relation) {
trace
.steps
.push(check_step("check_focus", None, "focus_on_target"));
true
} else if matches!(relation.as_str(), "none" | "trigger") {
keyboard::press_tab(page).await?;
let _ = stability::settle_after_action(page).await;
let after_tab = focus_relation(page, &target, &trigger).await;
let focus_selector = focus::capture_focus(page).await?.selector;
let Some(after_tab) = after_tab else {
trace.steps.push(check_step(
"check_focus_after_tab",
focus_selector,
"not_observable",
));
return Ok((trace, findings));
};
let ok = reached(&after_tab);
trace.steps.push(check_step(
"check_focus_after_tab",
focus_selector,
if ok {
"tab_reaches_target"
} else {
"focus_not_moved"
},
));
ok
} else {
let focus_selector = focus::capture_focus(page).await?.selector;
trace.steps.push(check_step(
"check_focus",
focus_selector,
"focus_before_target",
));
false
}
}
Target::Missing => {
trace
.steps
.push(check_step("check_focus", None, "target_missing"));
false
}
Target::NoFragment => {
let on_trigger = focus::capture_focus_backend_id(page).await == Some(trigger_id);
let snap = focus::capture_focus(page).await?;
let focus_on_body = snap.selector.as_deref().is_none_or(|s| {
let low = s.to_lowercase();
low == "body" || low == "html"
});
let stayed = on_trigger || focus_on_body;
trace.steps.push(check_step(
"check_focus",
snap.selector,
if stayed {
"focus_not_moved"
} else {
"focus_moved"
},
));
!stayed
}
};
if !moved {
findings.push(InteractiveFinding::new(
"SkipLink",
InteractiveFindingKind::SkipLinkFocusNotMoved,
None,
Severity::High,
journey_name,
None,
Some("after_skip_link".to_string()),
InteractiveFindingValues::default(),
));
}
Ok((trace, findings))
}
fn check_step(action: &str, focus: Option<String>, result: &str) -> JourneyStep {
JourneyStep {
action: action.to_string(),
target: None,
focus,
result: Some(result.to_string()),
snapshot_label: Some("after_skip_link".to_string()),
}
}