use crate::error::{BrowserError, Result};
use crate::tools::core::structured_tool_failure;
use crate::tools::{
DocumentActionResult, DocumentEnvelopeOptions, Tool, ToolContext, ToolResult,
build_document_envelope,
};
use schemars::JsonSchema;
use serde::de::Deserializer;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ScrollParams {
#[serde(skip_serializing_if = "Option::is_none")]
pub amount: Option<i32>,
}
#[derive(Default)]
pub struct ScrollTool;
const SCROLL_JS: &str = include_str!("scroll.js");
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
pub struct ViewportAfter {
pub scroll_y: i64,
pub is_at_top: bool,
pub is_at_bottom: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ScrollOutput {
#[serde(flatten)]
pub result: DocumentActionResult,
pub scrolled: i64,
pub is_at_bottom: bool,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub scroll_after: Option<ViewportAfter>,
#[serde(skip_serializing_if = "Option::is_none")]
pub viewport_after: Option<ViewportAfter>,
}
#[derive(Debug, Clone, Deserialize, Default)]
struct RawScrollOutput {
#[serde(alias = "actualScroll", deserialize_with = "deserialize_scroll_pixels")]
actual_scroll: i64,
#[serde(alias = "isAtBottom")]
is_at_bottom: bool,
#[serde(alias = "scrollY", deserialize_with = "deserialize_scroll_pixels")]
scroll_y: i64,
#[serde(alias = "isAtTop")]
is_at_top: bool,
}
fn deserialize_scroll_pixels<'de, D>(deserializer: D) -> std::result::Result<i64, D::Error>
where
D: Deserializer<'de>,
{
let value = serde_json::Value::deserialize(deserializer)?;
match value {
serde_json::Value::Number(number) => {
if let Some(value) = number.as_i64() {
return Ok(value);
}
if let Some(value) = number.as_u64() {
return i64::try_from(value).map_err(serde::de::Error::custom);
}
if let Some(value) = number.as_f64() {
if !value.is_finite() {
return Err(serde::de::Error::custom("scroll metric must be finite"));
}
let rounded = value.round();
if rounded < i64::MIN as f64 || rounded > i64::MAX as f64 {
return Err(serde::de::Error::custom("scroll metric is out of range"));
}
return Ok(rounded as i64);
}
Err(serde::de::Error::custom(
"unsupported numeric scroll metric",
))
}
other => Err(serde::de::Error::custom(format!(
"expected numeric scroll metric, got {}",
value_kind(&other)
))),
}
}
impl Tool for ScrollTool {
type Params = ScrollParams;
type Output = ScrollOutput;
fn name(&self) -> &str {
"scroll"
}
fn description(&self) -> &str {
"Scroll the page. Returns scroll_after; snapshot only for broader rereads."
}
fn execute_typed(&self, params: ScrollParams, context: &mut ToolContext) -> Result<ToolResult> {
let config = serde_json::json!({
"amount": params.amount
});
let scroll_js = build_scroll_js(&config);
context.record_browser_evaluation();
let result = context
.session
.evaluate(&scroll_js, true)
.map_err(|e| match e {
BrowserError::EvaluationFailed(reason) => BrowserError::ToolExecutionFailed {
tool: "scroll".to_string(),
reason,
},
other => other,
})?;
let result_json = match parse_raw_scroll_output(result.value) {
Ok(result_json) => result_json,
Err((reason, received_type)) => {
return Ok(context.finish(structured_tool_failure(
"invalid_scroll_payload",
reason,
None,
None,
Some(serde_json::json!({
"suggested_tool": "snapshot",
})),
Some(serde_json::json!({
"received_type": received_type,
})),
)));
}
};
context.invalidate_dom();
let envelope = build_document_envelope(context, None, DocumentEnvelopeOptions::minimal())?;
Ok(context.finish(ToolResult::success_with(build_scroll_output(
result_json,
DocumentActionResult::new("scroll", envelope.document),
))))
}
}
fn build_scroll_js(config: &serde_json::Value) -> String {
SCROLL_JS.replace("__SCROLL_CONFIG__", &config.to_string())
}
fn build_scroll_output(result_json: RawScrollOutput, result: DocumentActionResult) -> ScrollOutput {
let viewport_after = Some(ViewportAfter {
scroll_y: result_json.scroll_y,
is_at_top: result_json.is_at_top,
is_at_bottom: result_json.is_at_bottom,
});
let message = if result_json.is_at_bottom {
format!(
"Scrolled {} pixels. Reached the bottom of the page.",
result_json.actual_scroll
)
} else {
format!(
"Scrolled {} pixels. Did not reach the bottom of the page.",
result_json.actual_scroll
)
};
ScrollOutput {
result,
scrolled: result_json.actual_scroll,
is_at_bottom: result_json.is_at_bottom,
message,
scroll_after: viewport_after.clone(),
viewport_after,
}
}
fn parse_raw_scroll_output(
value: Option<serde_json::Value>,
) -> std::result::Result<RawScrollOutput, (String, &'static str)> {
match value {
Some(serde_json::Value::String(json_str)) => {
serde_json::from_str(&json_str).map_err(|error| {
(
format!("Failed to parse scroll result: {}", error),
"string",
)
})
}
Some(other) => {
let received_type = value_kind(&other);
serde_json::from_value(other).map_err(|error| {
(
format!("Failed to deserialize scroll result: {}", error),
received_type,
)
})
}
None => Err(("Scroll returned no data".to_string(), "null")),
}
}
fn value_kind(value: &serde_json::Value) -> &'static str {
match value {
serde_json::Value::Null => "null",
serde_json::Value::Bool(_) => "boolean",
serde_json::Value::Number(_) => "number",
serde_json::Value::String(_) => "string",
serde_json::Value::Array(_) => "array",
serde_json::Value::Object(_) => "object",
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::browser::BrowserSession;
use crate::browser::backend::{ScriptEvaluation, SessionBackend, TabDescriptor};
use crate::tools::{OPERATION_METRICS_METADATA_KEY, Tool, ToolContext};
use crate::{dom::DocumentMetadata, dom::DomTree};
use serde_json::Value;
use std::time::Duration;
struct InvalidScrollPayloadBackend;
#[test]
fn test_build_scroll_js_injects_config_without_placeholder() {
let scroll_js = build_scroll_js(&serde_json::json!({ "amount": 240 }));
assert!(scroll_js.contains(r#"const config = {"amount":240};"#));
assert!(!scroll_js.contains("__SCROLL_CONFIG__"));
}
impl SessionBackend for InvalidScrollPayloadBackend {
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> {
unreachable!("document_metadata is not used in this test")
}
fn extract_dom(&self) -> crate::error::Result<DomTree> {
unreachable!("extract_dom is not used in this test")
}
fn extract_dom_with_prefix(&self, _prefix: &str) -> crate::error::Result<DomTree> {
unreachable!("extract_dom_with_prefix is not used in this test")
}
fn evaluate(
&self,
_script: &str,
_await_promise: bool,
) -> crate::error::Result<ScriptEvaluation> {
Ok(ScriptEvaluation {
value: Some(Value::String("not-json".to_string())),
description: None,
type_name: Some("String".to_string()),
})
}
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_scroll_params_with_amount() {
let json = serde_json::json!({
"amount": 500
});
let params: ScrollParams = serde_json::from_value(json).unwrap();
assert_eq!(params.amount, Some(500));
}
#[test]
fn test_scroll_params_negative_amount() {
let json = serde_json::json!({
"amount": -300
});
let params: ScrollParams = serde_json::from_value(json).unwrap();
assert_eq!(params.amount, Some(-300));
}
#[test]
fn test_scroll_params_no_amount() {
let json = serde_json::json!({});
let params: ScrollParams = serde_json::from_value(json).unwrap();
assert_eq!(params.amount, None);
}
#[test]
fn test_parse_scroll_output_from_string_payload() {
let output = build_scroll_output(
parse_raw_scroll_output(Some(serde_json::Value::String(
r#"{"actual_scroll":420,"is_at_bottom":true,"scroll_y":860,"is_at_top":false}"#
.to_string(),
)))
.expect("scroll payload should parse"),
empty_result(),
);
assert_eq!(output.scrolled, 420);
assert!(output.is_at_bottom);
assert!(output.message.contains("Reached the bottom"));
assert_eq!(
output.viewport_after,
Some(ViewportAfter {
scroll_y: 860,
is_at_top: false,
is_at_bottom: true,
})
);
}
#[test]
fn test_parse_raw_scroll_output_rejects_invalid_payload() {
let error =
parse_raw_scroll_output(Some(serde_json::Value::String("not json".to_string())))
.expect_err("invalid scroll payload should fail");
assert!(error.0.contains("Failed to parse scroll result"));
assert_eq!(error.1, "string");
}
#[test]
fn test_parse_scroll_output_rounds_fractional_metrics_from_string_payload() {
let output = build_scroll_output(
parse_raw_scroll_output(Some(serde_json::Value::String(
r#"{"actualScroll":-2389.5,"isAtBottom":false,"scrollY":24675.5,"isAtTop":false}"#
.to_string(),
)))
.expect("fractional scroll payload should parse"),
empty_result(),
);
assert_eq!(output.scrolled, -2390);
assert_eq!(
output.viewport_after,
Some(ViewportAfter {
scroll_y: 24676,
is_at_top: false,
is_at_bottom: false,
})
);
}
#[test]
fn test_scroll_output_serializes_normalized_metric_names_and_adds_viewport_after() {
let output = ScrollOutput {
result: empty_result(),
scrolled: 120,
is_at_bottom: false,
message: "Scrolled 120 pixels. Did not reach the bottom of the page.".to_string(),
scroll_after: Some(ViewportAfter {
scroll_y: 240,
is_at_top: false,
is_at_bottom: false,
}),
viewport_after: Some(ViewportAfter {
scroll_y: 240,
is_at_top: false,
is_at_bottom: false,
}),
};
let value = serde_json::to_value(output).expect("scroll output should serialize");
assert_eq!(value["scrolled"], serde_json::json!(120));
assert_eq!(value["is_at_bottom"], serde_json::json!(false));
assert_eq!(value["action"], serde_json::json!("scroll"));
assert_eq!(value["scroll_after"], value["viewport_after"]);
assert_eq!(value["scroll_after"]["scroll_y"], serde_json::json!(240));
assert_eq!(value["viewport_after"]["scroll_y"], serde_json::json!(240));
assert_eq!(
value["viewport_after"]["is_at_top"],
serde_json::json!(false)
);
}
#[test]
fn test_scroll_tool_returns_structured_failure_for_invalid_payload() {
let session = BrowserSession::with_test_backend(InvalidScrollPayloadBackend);
let tool = ScrollTool;
let mut context = ToolContext::new(&session);
let result = tool
.execute_typed(ScrollParams { amount: None }, &mut context)
.expect("invalid scroll payload should stay a tool failure");
assert!(!result.success);
assert!(
result
.error
.as_deref()
.unwrap_or_default()
.contains("Failed to parse scroll result")
);
let data = result
.data
.expect("invalid scroll payload failure should include details");
assert_eq!(data["code"].as_str(), Some("invalid_scroll_payload"));
assert_eq!(data["details"]["received_type"].as_str(), Some("string"));
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(1));
}
fn empty_result() -> DocumentActionResult {
DocumentActionResult::new("scroll", crate::dom::DocumentMetadata::default())
}
}