viewpoint_core/page/locator/evaluation/
scroll.rs

1//! Scroll into view methods.
2
3use serde::Deserialize;
4use tracing::{debug, instrument};
5use viewpoint_cdp::protocol::dom::{BackendNodeId, ResolveNodeParams, ResolveNodeResult};
6use viewpoint_js::js;
7
8use super::super::Locator;
9use super::super::Selector;
10use crate::error::LocatorError;
11
12impl Locator<'_> {
13    /// Scroll the element into view if needed.
14    ///
15    /// This scrolls the element's parent container(s) to make the element visible.
16    ///
17    /// # Example
18    ///
19    /// ```no_run
20    /// use viewpoint_core::Page;
21    ///
22    /// # async fn example(page: &Page) -> Result<(), viewpoint_core::CoreError> {
23    /// page.locator(".footer").scroll_into_view_if_needed().await?;
24    /// # Ok(())
25    /// # }
26    /// ```
27    ///
28    /// # Errors
29    ///
30    /// Returns an error if the element cannot be found.
31    #[instrument(level = "debug", skip(self), fields(selector = ?self.selector))]
32    pub async fn scroll_into_view_if_needed(&self) -> Result<(), LocatorError> {
33        let _info = self.wait_for_actionable().await?;
34
35        debug!("Scrolling element into view");
36
37        // Handle Ref selector - lookup in ref map and resolve via CDP
38        if let Selector::Ref(ref_str) = &self.selector {
39            let backend_node_id = self.page.get_backend_node_id_for_ref(ref_str)?;
40            return self.scroll_into_view_by_backend_id(backend_node_id).await;
41        }
42
43        // Handle BackendNodeId selector
44        if let Selector::BackendNodeId(backend_node_id) = &self.selector {
45            return self.scroll_into_view_by_backend_id(*backend_node_id).await;
46        }
47
48        let selector_expr = self.selector.to_js_expression();
49        let js = js! {
50            (function() {
51                const elements = @{selector_expr};
52                if (elements.length === 0) return { found: false };
53
54                const el = elements[0];
55                el.scrollIntoView({ behavior: "instant", block: "center", inline: "center" });
56                return { found: true };
57            })()
58        };
59
60        let result = self.evaluate_js(&js).await?;
61        let found = result
62            .get("found")
63            .and_then(serde_json::Value::as_bool)
64            .unwrap_or(false);
65        if !found {
66            return Err(LocatorError::NotFound(format!("{:?}", self.selector)));
67        }
68
69        Ok(())
70    }
71
72    /// Scroll an element into view by backend node ID.
73    pub(super) async fn scroll_into_view_by_backend_id(
74        &self,
75        backend_node_id: BackendNodeId,
76    ) -> Result<(), LocatorError> {
77        // Resolve the backend node ID to a RemoteObject
78        let result: ResolveNodeResult = self
79            .page
80            .connection()
81            .send_command(
82                "DOM.resolveNode",
83                Some(ResolveNodeParams {
84                    node_id: None,
85                    backend_node_id: Some(backend_node_id),
86                    object_group: Some("viewpoint-scroll".to_string()),
87                    execution_context_id: None,
88                }),
89                Some(self.page.session_id()),
90            )
91            .await
92            .map_err(|_| {
93                LocatorError::NotFound(format!(
94                    "Could not resolve backend node ID {backend_node_id}: element may no longer exist"
95                ))
96            })?;
97
98        let object_id = result.object.object_id.ok_or_else(|| {
99            LocatorError::NotFound(format!(
100                "No object ID for backend node ID {backend_node_id}"
101            ))
102        })?;
103
104        // Call scrollIntoView on the resolved element
105        #[derive(Debug, Deserialize)]
106        struct CallResult {
107            result: viewpoint_cdp::protocol::runtime::RemoteObject,
108            #[serde(rename = "exceptionDetails")]
109            exception_details: Option<viewpoint_cdp::protocol::runtime::ExceptionDetails>,
110        }
111
112        let js_fn = js! {
113            (function() {
114                this.scrollIntoView({ behavior: "instant", block: "center", inline: "center" });
115                return { found: true };
116            })
117        };
118        // Strip outer parentheses for CDP functionDeclaration
119        let js_fn = js_fn.trim_start_matches('(').trim_end_matches(')');
120
121        let call_result: CallResult = self
122            .page
123            .connection()
124            .send_command(
125                "Runtime.callFunctionOn",
126                Some(serde_json::json!({
127                    "objectId": object_id,
128                    "functionDeclaration": js_fn,
129                    "returnByValue": true
130                })),
131                Some(self.page.session_id()),
132            )
133            .await?;
134
135        // Release the object
136        let _ = self
137            .page
138            .connection()
139            .send_command::<_, serde_json::Value>(
140                "Runtime.releaseObject",
141                Some(serde_json::json!({ "objectId": object_id })),
142                Some(self.page.session_id()),
143            )
144            .await;
145
146        if let Some(exception) = call_result.exception_details {
147            return Err(LocatorError::EvaluationError(exception.text));
148        }
149
150        Ok(())
151    }
152}