cdp_core/page/
frame.rs

1use super::page_core::Page;
2use crate::error::{CdpError, Result};
3use crate::page::element;
4use cdp_protocol::runtime;
5use serde_json::Value;
6use std::sync::Arc;
7use tokio::time::{Duration, sleep};
8
9/// Retry configuration shared by frame helpers.
10#[derive(Clone, Debug)]
11pub struct RetryConfig {
12    /// Maximum number of retries.
13    pub max_retries: u32,
14    /// Initial delay in milliseconds before retrying.
15    pub initial_delay_ms: u64,
16    /// Backoff multiplier applied between attempts (exponential backoff).
17    pub backoff_multiplier: f64,
18    /// Maximum delay in milliseconds between attempts.
19    pub max_delay_ms: u64,
20}
21
22impl Default for RetryConfig {
23    fn default() -> Self {
24        Self {
25            max_retries: 3,
26            initial_delay_ms: 100,
27            backoff_multiplier: 2.0,
28            max_delay_ms: 5000,
29        }
30    }
31}
32
33impl RetryConfig {
34    /// Creates a conservative retry profile (more attempts, longer delays).
35    pub fn conservative() -> Self {
36        Self {
37            max_retries: 5,
38            initial_delay_ms: 200,
39            backoff_multiplier: 2.0,
40            max_delay_ms: 10000,
41        }
42    }
43
44    /// Creates an aggressive retry profile (fewer attempts, short delays).
45    pub fn aggressive() -> Self {
46        Self {
47            max_retries: 2,
48            initial_delay_ms: 50,
49            backoff_multiplier: 1.5,
50            max_delay_ms: 2000,
51        }
52    }
53
54    /// Disables retries entirely.
55    pub fn no_retry() -> Self {
56        Self {
57            max_retries: 0,
58            initial_delay_ms: 0,
59            backoff_multiplier: 1.0,
60            max_delay_ms: 0,
61        }
62    }
63}
64
65#[derive(Clone)]
66pub struct Frame {
67    pub id: String, // frameId
68
69    // Hold a reference to the owning page instead of duplicating state.
70    pub page: Arc<Page>,
71}
72
73impl Frame {
74    // Construct a frame wrapper.
75    pub fn new(id: String, page: Arc<Page>) -> Self {
76        Self { id, page }
77    }
78
79    /// Returns the frame identifier exposed by CDP.
80    pub fn id(&self) -> &str {
81        &self.id
82    }
83
84    /// Resolves the execution context associated with the frame.
85    pub async fn execution_context_id(&self) -> Result<u32> {
86        self.page
87            .contexts
88            .lock()
89            .await
90            .get(&self.id)
91            .cloned()
92            .ok_or_else(|| {
93                CdpError::frame(format!("Execution context not found for frame {}", self.id))
94            })
95    }
96
97    /// Calls JavaScript within the frame's execution context.
98    pub async fn call_function_on(
99        &self,
100        function_declaration: &str,
101        args: Vec<Value>,
102    ) -> Result<Value> {
103        let context_id = self.execution_context_id().await?;
104        let params = runtime::CallFunctionOn {
105            function_declaration: function_declaration.to_string(),
106            object_id: None,
107            arguments: Some(
108                args.into_iter()
109                    .map(|v| runtime::CallArgument {
110                        value: Some(v),
111                        unserializable_value: None,
112                        object_id: None,
113                    })
114                    .collect(),
115            ),
116            silent: None,
117            return_by_value: Some(true),
118            generate_preview: None,
119            user_gesture: None,
120            await_promise: Some(true),
121            execution_context_id: Some(context_id),
122            object_group: None,
123            throw_on_side_effect: None,
124            unique_context_id: None,
125            serialization_options: None,
126        };
127        let result: runtime::CallFunctionOnReturnObject =
128            self.page.session.send_command(params, None).await?;
129        if let Some(details) = result.exception_details.as_ref() {
130            return Err(CdpError::frame(format!(
131                "JavaScript execution failed: {:?}",
132                details
133            )));
134        }
135        // Return the JSON payload produced by the browser.
136        Ok(result.result.value.unwrap_or(Value::Null))
137    }
138
139    /// Evaluates JavaScript within the frame's execution context.
140    pub async fn evaluate(&self, script: &str) -> Result<Value> {
141        let context_id = self.execution_context_id().await?;
142        let params = runtime::Evaluate {
143            expression: script.to_string(),
144            object_group: None,
145            include_command_line_api: None,
146            silent: None,
147            context_id: Some(context_id),
148            return_by_value: Some(true),
149            generate_preview: None,
150            user_gesture: None,
151            await_promise: Some(true),
152            throw_on_side_effect: None,
153            timeout: None,
154            disable_breaks: None,
155            repl_mode: None,
156            allow_unsafe_eval_blocked_by_csp: None,
157            unique_context_id: None,
158            serialization_options: None,
159        };
160        let result: runtime::EvaluateReturnObject =
161            self.page.session.send_command(params, None).await?;
162        if let Some(details) = result.exception_details.as_ref() {
163            return Err(CdpError::frame(format!(
164                "JavaScript execution failed: {:?}",
165                details
166            )));
167        }
168        Ok(result.result.value.unwrap_or(Value::Null))
169    }
170
171    /// Returns the window name for the frame, if any.
172    pub async fn name(&self) -> Result<Option<String>> {
173        let script = "window.name";
174        let result = self.evaluate(script).await?;
175        Ok(result.as_str().map(|s| s.to_string()))
176    }
177
178    /// Returns the current URL navigating inside the frame.
179    pub async fn url(&self) -> Result<String> {
180        let script = "window.location.href";
181        let result = self.evaluate(script).await?;
182        result
183            .as_str()
184            .map(|s| s.to_string())
185            .ok_or_else(|| CdpError::frame(format!("Failed to resolve URL for frame {}", self.id)))
186    }
187
188    /// Returns `true` if the frame no longer has a valid execution context.
189    pub async fn is_detached(&self) -> bool {
190        // Missing context indicates the frame was detached.
191        self.page.contexts.lock().await.get(&self.id).is_none()
192    }
193
194    // ===== DOM query helpers =================================================
195
196    /// Queries the first element in the frame using either CSS or XPath.
197    ///
198    /// CSS selectors look like `div.class` or `#id`. XPath selectors start with
199    /// `xpath:` or `/`, for example `//div[@class='test']`.
200    pub async fn query_selector(&self, selector: &str) -> Result<Option<element::ElementHandle>> {
201        // Treat XPath selectors specially.
202        if Self::is_xpath(selector) {
203            return self.query_selector_xpath(selector).await;
204        }
205
206        // Otherwise fall back to CSS selectors.
207        use cdp_protocol::dom::{
208            DescribeNode, DescribeNodeReturnObject, GetDocument, GetDocumentReturnObject,
209            QuerySelector, QuerySelectorReturnObject,
210        };
211
212        // 1. Retrieve the root node of the frame document.
213        let doc_result: GetDocumentReturnObject = self
214            .page
215            .session
216            .send_command(
217                GetDocument {
218                    depth: Some(0), // Only fetch the root node.
219                    pierce: Some(false),
220                },
221                None,
222            )
223            .await?;
224
225        let root_node_id = doc_result.root.node_id;
226
227        // 2. Use DOM.querySelector to find the first match.
228        let query_result = self
229            .page
230            .session
231            .send_command::<_, QuerySelectorReturnObject>(
232                QuerySelector {
233                    node_id: root_node_id,
234                    selector: selector.to_string(),
235                },
236                None,
237            )
238            .await?;
239
240        if query_result.node_id == 0 {
241            return Ok(None);
242        }
243
244        let query_node_id = query_result.node_id;
245
246        // 3. Hydrate the node into an element handle.
247        let describe_result = self
248            .page
249            .session
250            .send_command::<_, DescribeNodeReturnObject>(
251                DescribeNode {
252                    node_id: Some(query_node_id),
253                    backend_node_id: None,
254                    object_id: None,
255                    depth: None,
256                    pierce: None,
257                },
258                None,
259            )
260            .await?;
261
262        Ok(Some(element::ElementHandle {
263            backend_node_id: describe_result.node.backend_node_id,
264            node_id: Some(query_node_id),
265            page: Arc::clone(&self.page),
266        }))
267    }
268
269    /// Queries all matching elements in the frame using CSS or XPath.
270    pub async fn query_selector_all(&self, selector: &str) -> Result<Vec<element::ElementHandle>> {
271        // Treat XPath selectors specially.
272        if Self::is_xpath(selector) {
273            return self.query_selector_all_xpath(selector).await;
274        }
275
276        // Otherwise fall back to CSS selectors.
277        use cdp_protocol::dom::{
278            DescribeNode, DescribeNodeReturnObject, GetDocument, GetDocumentReturnObject,
279            QuerySelectorAll, QuerySelectorAllReturnObject,
280        };
281
282        // 1. Retrieve the root node of the frame document.
283        let doc_result: GetDocumentReturnObject = self
284            .page
285            .session
286            .send_command(
287                GetDocument {
288                    depth: Some(0), // Only fetch the root node.
289                    pierce: Some(false),
290                },
291                None,
292            )
293            .await?;
294
295        let root_node_id = doc_result.root.node_id;
296
297        // 2. Collect all matching node identifiers.
298        let query_result = self
299            .page
300            .session
301            .send_command::<_, QuerySelectorAllReturnObject>(
302                QuerySelectorAll {
303                    node_id: root_node_id,
304                    selector: selector.to_string(),
305                },
306                None,
307            )
308            .await?;
309
310        let mut elements = Vec::new();
311        for node_id in query_result.node_ids {
312            if node_id == 0 {
313                continue;
314            }
315
316            let describe_result = self
317                .page
318                .session
319                .send_command::<_, DescribeNodeReturnObject>(
320                    DescribeNode {
321                        node_id: Some(node_id),
322                        backend_node_id: None,
323                        object_id: None,
324                        depth: None,
325                        pierce: None,
326                    },
327                    None,
328                )
329                .await?;
330
331            elements.push(element::ElementHandle {
332                backend_node_id: describe_result.node.backend_node_id,
333                node_id: Some(node_id),
334                page: Arc::clone(&self.page),
335            });
336        }
337
338        Ok(elements)
339    }
340
341    // ===== Retry-enabled helpers ============================================
342
343    /// Retries [`Self::call_function_on`] using the provided configuration.
344    pub async fn call_function_on_with_retry(
345        &self,
346        function_declaration: &str,
347        args: Vec<Value>,
348        config: RetryConfig,
349    ) -> Result<Value> {
350        let mut attempt = 0;
351        let mut delay_ms = config.initial_delay_ms;
352
353        loop {
354            match self
355                .call_function_on(function_declaration, args.clone())
356                .await
357            {
358                Ok(result) => return Ok(result),
359                Err(err) => {
360                    if attempt >= config.max_retries {
361                        return Err(CdpError::frame(format!(
362                            "call_function_on failed after {} retries: {}",
363                            config.max_retries, err
364                        )));
365                    }
366
367                    // If the frame is gone, bail out immediately.
368                    if self.is_detached().await {
369                        return Err(CdpError::frame(format!(
370                            "Frame '{}' is detached; cannot retry",
371                            self.id
372                        )));
373                    }
374
375                    // Wait for the next attempt using the configured backoff.
376                    sleep(Duration::from_millis(delay_ms)).await;
377                    delay_ms = ((delay_ms as f64) * config.backoff_multiplier) as u64;
378                    delay_ms = delay_ms.min(config.max_delay_ms);
379                    attempt += 1;
380                }
381            }
382        }
383    }
384
385    /// Retries [`Self::evaluate`] using the provided configuration.
386    pub async fn evaluate_with_retry(&self, script: &str, config: RetryConfig) -> Result<Value> {
387        let mut attempt = 0;
388        let mut delay_ms = config.initial_delay_ms;
389
390        loop {
391            match self.evaluate(script).await {
392                Ok(result) => return Ok(result),
393                Err(err) => {
394                    if attempt >= config.max_retries {
395                        return Err(CdpError::frame(format!(
396                            "evaluate failed after {} retries: {}",
397                            config.max_retries, err
398                        )));
399                    }
400
401                    // If the frame is gone, bail out immediately.
402                    if self.is_detached().await {
403                        return Err(CdpError::frame(format!(
404                            "Frame '{}' is detached; cannot retry",
405                            self.id
406                        )));
407                    }
408
409                    // Wait for the next attempt using the configured backoff.
410                    sleep(Duration::from_millis(delay_ms)).await;
411                    delay_ms = ((delay_ms as f64) * config.backoff_multiplier) as u64;
412                    delay_ms = delay_ms.min(config.max_delay_ms);
413                    attempt += 1;
414                }
415            }
416        }
417    }
418
419    // ===== XPath helpers =====================================================
420
421    /// Returns true if the selector is interpreted as XPath (starts with
422    /// `xpath:`, `/`, or `(`).
423    fn is_xpath(selector: &str) -> bool {
424        selector.starts_with("xpath:") || selector.starts_with("/") || selector.starts_with("(")
425    }
426
427    /// Strips the optional `xpath:` prefix from the selector.
428    fn normalize_xpath(selector: &str) -> &str {
429        selector.strip_prefix("xpath:").unwrap_or(selector)
430    }
431
432    /// XPath-powered version of [`Self::query_selector`].
433    async fn query_selector_xpath(&self, xpath: &str) -> Result<Option<element::ElementHandle>> {
434        use cdp_protocol::dom::{
435            DescribeNode, DescribeNodeReturnObject, DiscardSearchResults, GetSearchResults,
436            GetSearchResultsReturnObject, PerformSearch, PerformSearchReturnObject,
437        };
438
439        let xpath = Self::normalize_xpath(xpath);
440
441        // 1. Execute the global search.
442        let search_result: PerformSearchReturnObject = self
443            .page
444            .session
445            .send_command(
446                PerformSearch {
447                    query: xpath.to_string(),
448                    include_user_agent_shadow_dom: Some(true),
449                },
450                None,
451            )
452            .await?;
453
454        let search_id = search_result.search_id;
455        let result_count = search_result.result_count;
456
457        if result_count == 0 {
458            // Discard the search results to keep the browser clean.
459            let _ = self
460                .page
461                .session
462                .send_command::<_, ()>(
463                    DiscardSearchResults {
464                        search_id: search_id.clone(),
465                    },
466                    None,
467                )
468                .await;
469            return Ok(None);
470        }
471
472        // 2. Fetch the first result only.
473        let get_results: GetSearchResultsReturnObject = self
474            .page
475            .session
476            .send_command(
477                GetSearchResults {
478                    search_id: search_id.clone(),
479                    from_index: 0,
480                    to_index: 1,
481                },
482                None,
483            )
484            .await?;
485
486        // 3. Discard the search results to avoid leaking handles.
487        let _ = self
488            .page
489            .session
490            .send_command::<_, ()>(DiscardSearchResults { search_id }, None)
491            .await;
492
493        if get_results.node_ids.is_empty() {
494            return Ok(None);
495        }
496
497        let node_id = get_results.node_ids[0];
498
499        if node_id == 0 {
500            eprintln!("Warning: XPath search returned an invalid node_id (0)");
501            return Ok(None);
502        }
503
504        // 4. Describe the node and promote it to an element handle.
505        let describe_result = self
506            .page
507            .session
508            .send_command::<_, DescribeNodeReturnObject>(
509                DescribeNode {
510                    node_id: Some(node_id),
511                    backend_node_id: None,
512                    object_id: None,
513                    depth: None,
514                    pierce: None,
515                },
516                None,
517            )
518            .await?;
519
520        Ok(Some(element::ElementHandle {
521            backend_node_id: describe_result.node.backend_node_id,
522            node_id: Some(node_id),
523            page: Arc::clone(&self.page),
524        }))
525    }
526
527    /// XPath-powered version of [`Self::query_selector_all`].
528    async fn query_selector_all_xpath(&self, xpath: &str) -> Result<Vec<element::ElementHandle>> {
529        use cdp_protocol::dom::{
530            DescribeNode, DescribeNodeReturnObject, DiscardSearchResults, GetSearchResults,
531            GetSearchResultsReturnObject, PerformSearch, PerformSearchReturnObject,
532        };
533
534        let xpath = Self::normalize_xpath(xpath);
535
536        // 1. Execute the global search.
537        let search_result: PerformSearchReturnObject = self
538            .page
539            .session
540            .send_command(
541                PerformSearch {
542                    query: xpath.to_string(),
543                    include_user_agent_shadow_dom: Some(true),
544                },
545                None,
546            )
547            .await?;
548
549        let search_id = search_result.search_id;
550        let result_count = search_result.result_count;
551
552        if result_count == 0 {
553            // Discard the search results to keep the browser clean.
554            let _ = self
555                .page
556                .session
557                .send_command::<_, ()>(
558                    DiscardSearchResults {
559                        search_id: search_id.clone(),
560                    },
561                    None,
562                )
563                .await;
564            return Ok(Vec::new());
565        }
566
567        // 2. Fetch every node that matched the query.
568        let get_results: GetSearchResultsReturnObject = self
569            .page
570            .session
571            .send_command(
572                GetSearchResults {
573                    search_id: search_id.clone(),
574                    from_index: 0,
575                    to_index: result_count,
576                },
577                None,
578            )
579            .await?;
580
581        // 3. Discard the search results to avoid leaking handles.
582        let _ = self
583            .page
584            .session
585            .send_command::<_, ()>(DiscardSearchResults { search_id }, None)
586            .await;
587
588        // 4. Create element handles for the retrieved node identifiers.
589        let mut elements = Vec::new();
590        for node_id in get_results.node_ids {
591            if node_id == 0 {
592                continue;
593            }
594
595            let describe_result = self
596                .page
597                .session
598                .send_command::<_, DescribeNodeReturnObject>(
599                    DescribeNode {
600                        node_id: Some(node_id),
601                        backend_node_id: None,
602                        object_id: None,
603                        depth: None,
604                        pierce: None,
605                    },
606                    None,
607                )
608                .await?;
609
610            elements.push(element::ElementHandle {
611                backend_node_id: describe_result.node.backend_node_id,
612                node_id: Some(node_id),
613                page: Arc::clone(&self.page),
614            });
615        }
616
617        Ok(elements)
618    }
619}