use crate::browser::commands::{
BrowserCommand, BrowserCommandResult, InteractionCommand, InteractionCommandResult,
TargetedInteractionRequest,
};
use crate::dom::{Cursor, NodeRef};
use crate::error::{BrowserError, Result};
#[cfg(test)]
use crate::tools::browser_kernel::render_browser_kernel_script;
use crate::tools::{
TargetResolution, Tool, ToolContext, ToolResult,
actionability::ActionabilityPredicate,
core::PublicTarget,
core::TargetedActionResult,
services::interaction::{
ActionabilityWaitState, DEFAULT_ACTIONABILITY_TIMEOUT_MS, build_actionability_failure,
build_interaction_failure, build_interaction_handoff, decode_action_result,
resolve_interaction_target, wait_for_actionability,
},
};
use schemars::{JsonSchema, Schema, SchemaGenerator};
use serde::de::Deserializer;
use serde::{Deserialize, Serialize};
use std::borrow::Cow;
#[cfg(test)]
use std::sync::OnceLock;
#[cfg(test)]
const HOVER_JS: &str = include_str!("hover.js");
#[cfg(test)]
static HOVER_SHELL: OnceLock<crate::tools::browser_kernel::BrowserKernelTemplateShell> =
OnceLock::new();
#[derive(Debug, Clone, Serialize)]
pub struct HoverParams {
#[serde(skip_serializing_if = "Option::is_none")]
pub selector: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub index: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub node_ref: Option<NodeRef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub cursor: Option<Cursor>,
}
#[derive(Debug, Clone, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
struct StrictHoverParams {
pub target: PublicTarget,
}
impl From<StrictHoverParams> for HoverParams {
fn from(params: StrictHoverParams) -> Self {
let (selector, cursor) = params.target.into_selector_or_cursor();
Self {
selector,
index: None,
node_ref: None,
cursor,
}
}
}
impl<'de> Deserialize<'de> for HoverParams {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: Deserializer<'de>,
{
StrictHoverParams::deserialize(deserializer).map(Into::into)
}
}
impl JsonSchema for HoverParams {
fn schema_name() -> Cow<'static, str> {
"HoverParams".into()
}
fn json_schema(generator: &mut SchemaGenerator) -> Schema {
StrictHoverParams::json_schema(generator)
}
}
#[derive(Default)]
pub struct HoverTool;
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct HoverElement {
pub tag_name: String,
pub id: String,
pub class_name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct HoverOutput {
#[serde(flatten)]
pub result: TargetedActionResult,
pub element: HoverElement,
}
impl Tool for HoverTool {
type Params = HoverParams;
type Output = HoverOutput;
fn name(&self) -> &str {
"hover"
}
fn description(&self) -> &str {
"Reveal hover state. Usually after snapshot; next snapshot or click."
}
fn execute_typed(&self, params: HoverParams, context: &mut ToolContext) -> Result<ToolResult> {
let HoverParams {
selector,
index,
node_ref,
cursor,
} = params;
let target = match resolve_interaction_target(
"hover", selector, index, node_ref, cursor, context,
)? {
TargetResolution::Resolved(target) => target,
TargetResolution::Failure(failure) => return Ok(context.finish(failure)),
};
let predicates = hover_actionability_predicates();
match wait_for_actionability(
context,
&target,
predicates,
DEFAULT_ACTIONABILITY_TIMEOUT_MS,
)? {
ActionabilityWaitState::Ready => {}
ActionabilityWaitState::TimedOut(probe) => {
return build_actionability_failure(
"hover",
context.session,
&target,
&probe,
predicates,
None,
)
.map(|result| context.finish(result));
}
}
context.record_browser_evaluation();
let result = context
.session
.execute_command(BrowserCommand::Interaction(InteractionCommand::Hover(
TargetedInteractionRequest {
selector: target.selector.clone(),
target_index: target
.cursor
.as_ref()
.map(|cursor| cursor.index)
.or(target.index),
},
)))
.map_err(|e| match e {
BrowserError::EvaluationFailed(reason) => BrowserError::ToolExecutionFailed {
tool: "hover".to_string(),
reason,
},
other => other,
})?;
let BrowserCommandResult::Interaction(InteractionCommandResult::Hover(hover_result)) =
result
else {
return Err(BrowserError::ToolExecutionFailed {
tool: "hover".to_string(),
reason: "Browser command returned an unexpected result for hover".to_string(),
});
};
let hover_result = match parse_hover_result(Some(
serde_json::to_value(hover_result).map_err(BrowserError::from)?,
)) {
Ok(result) => result,
Err(reason) => {
return Ok(context.finish(ToolResult::failure_with(
reason.clone(),
serde_json::json!({
"code": "invalid_hover_payload",
"error": reason,
"recovery": {
"suggested_tool": "snapshot",
}
}),
)));
}
};
match hover_result {
HoverParseResult::Success(element) => {
let handoff = build_interaction_handoff(context, &target)?;
Ok(context.finish(ToolResult::success_with(HoverOutput {
result: TargetedActionResult::new(
"hover",
handoff.document,
handoff.target_before,
handoff.target_after,
handoff.target_status,
),
element,
})))
}
HoverParseResult::Failure { code, error } => build_interaction_failure(
"hover",
context.session,
&target,
code,
error,
Vec::new(),
None,
)
.map(|result| context.finish(result)),
}
}
}
#[cfg(test)]
fn build_hover_js(config: &serde_json::Value) -> String {
render_browser_kernel_script(&HOVER_SHELL, HOVER_JS, "__HOVER_CONFIG__", config)
}
#[derive(Debug)]
enum HoverParseResult {
Success(HoverElement),
Failure { code: String, error: String },
}
#[derive(Debug, Deserialize)]
struct RawHoverResult {
success: bool,
#[serde(default)]
code: Option<String>,
#[serde(default)]
error: Option<String>,
#[serde(default)]
tag_name: Option<String>,
#[serde(default)]
id: Option<String>,
#[serde(default)]
class_name: Option<String>,
}
fn parse_hover_result(
value: Option<serde_json::Value>,
) -> std::result::Result<HoverParseResult, String> {
let mut result_json = decode_action_result(
value,
serde_json::json!({
"success": false,
"code": "target_detached",
"error": "Element is no longer present"
}),
)
.map_err(|error| format!("Failed to parse hover result: {}", error))?;
promote_legacy_hover_fields(&mut result_json);
let result: RawHoverResult = serde_json::from_value(result_json)
.map_err(|error| format!("Failed to parse hover result: {}", error))?;
if result.success {
Ok(HoverParseResult::Success(HoverElement {
tag_name: required_hover_value(result.tag_name, "tag_name")?,
id: required_hover_value(result.id, "id")?,
class_name: required_hover_value(result.class_name, "class_name")?,
}))
} else {
Ok(HoverParseResult::Failure {
code: result.code.unwrap_or_else(|| "target_detached".to_string()),
error: result.error.unwrap_or_else(|| "Hover failed".to_string()),
})
}
}
fn promote_legacy_hover_fields(result_json: &mut serde_json::Value) {
let Some(object) = result_json.as_object_mut() else {
return;
};
for (legacy, normalized) in [("tagName", "tag_name"), ("className", "class_name")] {
if object.contains_key(normalized) {
continue;
}
if let Some(value) = object.remove(legacy) {
object.insert(normalized.to_string(), value);
}
}
}
fn required_hover_value(
value: Option<String>,
field: &'static str,
) -> std::result::Result<String, String> {
value.ok_or_else(|| {
format!("Hover returned an incomplete success payload: missing string field '{field}'")
})
}
fn hover_actionability_predicates() -> &'static [ActionabilityPredicate] {
&[
ActionabilityPredicate::Present,
ActionabilityPredicate::Visible,
ActionabilityPredicate::Stable,
ActionabilityPredicate::ReceivesEvents,
ActionabilityPredicate::UnobscuredCenter,
]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::browser::BrowserSession;
use crate::browser::backend::{ScriptEvaluation, SessionBackend, TabDescriptor};
use crate::browser::commands::{ActionabilityProbeResult, HoverCommandResult};
use crate::dom::{AriaChild, AriaNode, DocumentMetadata, DomTree};
use crate::tools::{OPERATION_METRICS_METADATA_KEY, Tool, ToolContext};
use schemars::schema_for;
use serde_json::Value;
use serde_json::json;
use std::time::Duration;
struct InvalidHoverPayloadBackend;
impl InvalidHoverPayloadBackend {
fn dom() -> DomTree {
let mut root = AriaNode::fragment();
root.children.push(AriaChild::Node(Box::new(
AriaNode::new("button", "Fake target")
.with_index(0)
.with_box(true, Some("pointer".to_string())),
)));
let mut dom = DomTree::new(root);
dom.document = DocumentMetadata {
document_id: "tab-1".to_string(),
revision: "fake:1".to_string(),
url: "https://example.com".to_string(),
title: "Example".to_string(),
ready_state: "complete".to_string(),
frames: Vec::new(),
};
dom.replace_selectors(vec!["#fake-target".to_string()]);
dom
}
}
impl SessionBackend for InvalidHoverPayloadBackend {
fn navigate(&self, _url: &str) -> crate::error::Result<()> {
unreachable!("navigate is not used in this test")
}
fn wait_for_navigation(&self) -> crate::error::Result<()> {
unreachable!("wait_for_navigation is not used in this test")
}
fn wait_for_document_ready_with_timeout(
&self,
_timeout: Duration,
) -> crate::error::Result<()> {
unreachable!("wait_for_document_ready_with_timeout is not used in this test")
}
fn document_metadata(&self) -> crate::error::Result<DocumentMetadata> {
Ok(Self::dom().document)
}
fn extract_dom(&self) -> crate::error::Result<DomTree> {
Ok(Self::dom())
}
fn extract_dom_with_prefix(&self, _prefix: &str) -> crate::error::Result<DomTree> {
Ok(Self::dom())
}
fn evaluate(
&self,
script: &str,
_await_promise: bool,
) -> crate::error::Result<ScriptEvaluation> {
if script.contains("\"predicates\"") {
return Ok(ScriptEvaluation {
value: Some(serde_json::json!({
"present": true,
"visible": true,
"stable": true,
"in_viewport": true,
"receives_events": true,
"unobscured_center": true,
})),
description: None,
type_name: Some("Object".to_string()),
});
}
if script.contains("MouseEvent(\"mouseover\"") {
return Ok(ScriptEvaluation {
value: Some(Value::String(
serde_json::json!({
"success": true,
"id": "fake-target",
"class_name": "fake",
})
.to_string(),
)),
description: None,
type_name: Some("String".to_string()),
});
}
unreachable!("unexpected script in invalid hover payload test: {script}");
}
fn execute_command(
&self,
command: BrowserCommand,
) -> crate::error::Result<BrowserCommandResult> {
match command {
BrowserCommand::ActionabilityProbe(_) => Ok(
BrowserCommandResult::ActionabilityProbe(ActionabilityProbeResult {
present: true,
visible: Some(true),
stable: Some(true),
receives_events: Some(true),
in_viewport: Some(true),
unobscured_center: Some(true),
..ActionabilityProbeResult::default()
}),
),
BrowserCommand::Interaction(InteractionCommand::Hover(_)) => {
Ok(BrowserCommandResult::Interaction(
InteractionCommandResult::Hover(HoverCommandResult {
success: true,
code: None,
error: None,
tag_name: None,
id: Some("fake-target".to_string()),
class_name: Some("fake".to_string()),
}),
))
}
command => {
unreachable!("unexpected command in invalid hover payload test: {command:?}")
}
}
}
fn capture_screenshot(&self, _full_page: bool) -> crate::error::Result<Vec<u8>> {
unreachable!("capture_screenshot is not used in this test")
}
fn press_key(&self, _key: &str) -> crate::error::Result<()> {
unreachable!("press_key is not used in this test")
}
fn list_tabs(&self) -> crate::error::Result<Vec<TabDescriptor>> {
Ok(vec![TabDescriptor {
id: "tab-1".to_string(),
title: "Test Tab".to_string(),
url: "about:blank".to_string(),
}])
}
fn active_tab(&self) -> crate::error::Result<TabDescriptor> {
unreachable!("active_tab is not used in this test")
}
fn open_tab(&self, _url: &str) -> crate::error::Result<TabDescriptor> {
unreachable!("open_tab is not used in this test")
}
fn activate_tab(&self, _tab_id: &str) -> crate::error::Result<()> {
unreachable!("activate_tab is not used in this test")
}
fn close_tab(&self, _tab_id: &str, _with_unload: bool) -> crate::error::Result<()> {
unreachable!("close_tab is not used in this test")
}
fn close(&self) -> crate::error::Result<()> {
unreachable!("close is not used in this test")
}
}
#[test]
fn test_hover_params_deserializes_strict_target_and_rejects_legacy_fields() {
let params: HoverParams = serde_json::from_value(json!({
"target": {
"kind": "selector",
"selector": "#hover-btn"
}
}))
.expect("strict hover target should deserialize");
assert_eq!(params.selector.as_deref(), Some("#hover-btn"));
assert_eq!(params.index, None);
assert_eq!(params.node_ref, None);
assert_eq!(params.cursor, None);
let plain_string_params: HoverParams = serde_json::from_value(json!({
"target": "#hover-btn"
}))
.expect("plain string selector target should deserialize");
assert_eq!(plain_string_params.selector.as_deref(), Some("#hover-btn"));
assert_eq!(plain_string_params.index, None);
assert_eq!(plain_string_params.node_ref, None);
assert_eq!(plain_string_params.cursor, None);
let error = serde_json::from_value::<HoverParams>(json!({
"selector": "#hover-btn"
}))
.expect_err("legacy selector field should be rejected");
assert!(error.to_string().contains("unknown field `selector`"));
let schema = schema_for!(HoverParams);
let schema_json = serde_json::to_value(&schema).expect("schema should serialize");
let properties = schema_json
.get("properties")
.and_then(|value| value.as_object())
.expect("hover params schema should expose properties");
assert!(properties.contains_key("target"));
assert!(!properties.contains_key("selector"));
assert!(!properties.contains_key("index"));
assert!(!properties.contains_key("node_ref"));
assert!(!properties.contains_key("cursor"));
}
#[test]
fn test_parse_hover_result_success() {
let result = parse_hover_result(Some(serde_json::Value::String(
r#"{"success":true,"tag_name":"BUTTON","id":"save","class_name":"primary"}"#
.to_string(),
)))
.expect("hover result should parse");
match result {
HoverParseResult::Success(element) => {
assert_eq!(element.tag_name, "BUTTON");
assert_eq!(element.id, "save");
assert_eq!(element.class_name, "primary");
}
HoverParseResult::Failure { error, .. } => panic!("unexpected failure: {error}"),
}
}
#[test]
fn test_parse_hover_result_failure_uses_code_and_error() {
let result = parse_hover_result(Some(serde_json::json!({
"success": false,
"code": "target_detached",
"error": "Element not found"
})))
.expect("hover result should parse");
match result {
HoverParseResult::Failure { code, error } => {
assert_eq!(code, "target_detached");
assert_eq!(error, "Element not found");
}
HoverParseResult::Success(_) => panic!("expected failure"),
}
}
#[test]
fn test_parse_hover_result_rejects_invalid_json_string() {
let error = parse_hover_result(Some(serde_json::Value::String("not-json".to_string())))
.expect_err("invalid JSON should fail");
assert!(error.contains("Failed to parse hover result"));
assert!(error.contains("JSON error"));
}
#[test]
fn test_parse_hover_result_rejects_incomplete_success_payload() {
let error = parse_hover_result(Some(serde_json::json!({
"success": true,
"id": "save",
"class_name": "primary"
})))
.expect_err("incomplete hover success payload should fail");
assert!(error.contains("missing string field 'tag_name'"));
}
#[test]
fn test_hover_tool_returns_structured_failure_for_invalid_payload() {
let session = BrowserSession::with_test_backend(InvalidHoverPayloadBackend);
let tool = HoverTool;
let mut context = ToolContext::new(&session);
let result = tool
.execute_typed(
HoverParams {
selector: Some("#fake-target".to_string()),
index: None,
node_ref: None,
cursor: None,
},
&mut context,
)
.expect("invalid hover payload should stay a tool failure");
assert!(!result.success);
assert!(
result
.error
.as_deref()
.unwrap_or_default()
.contains("missing string field 'tag_name'")
);
let data = result
.data
.expect("invalid hover payload failure should include details");
assert_eq!(data["code"].as_str(), Some("invalid_hover_payload"));
assert_eq!(
data["recovery"]["suggested_tool"].as_str(),
Some("snapshot")
);
let metrics = result.metadata[OPERATION_METRICS_METADATA_KEY]
.as_object()
.expect("metrics metadata should be present on failures");
assert_eq!(metrics["browser_evaluations"].as_u64(), Some(3));
}
#[test]
fn test_hover_js_prefers_selector_before_target_index() {
let hover_js = build_hover_js(&serde_json::json!({
"selector": "#save",
"target_index": 1,
}));
assert!(hover_js.contains("function resolveTargetMatch(config, options)"));
assert!(hover_js.contains("const element = resolveTargetElement(config);"));
assert!(hover_js.contains("querySelectorAcrossScopes("));
assert!(hover_js.contains("searchActionableIndex(config.target_index)"));
}
}