viewpoint_core/page/frame/
mod.rs

1//! Frame management and navigation.
2//!
3//! Frames represent separate browsing contexts within a page, typically
4//! created by `<iframe>` elements. Each frame has its own DOM, JavaScript
5//! context, and URL.
6
7// Allow dead code for frame scaffolding (spec: frames)
8
9use std::sync::Arc;
10use std::time::Duration;
11
12use parking_lot::RwLock;
13use tracing::{debug, info, instrument};
14use viewpoint_cdp::protocol::page::{NavigateParams, NavigateResult};
15use viewpoint_cdp::protocol::runtime::EvaluateParams;
16use viewpoint_cdp::CdpConnection;
17
18use crate::error::{NavigationError, PageError};
19use crate::wait::{DocumentLoadState, LoadStateWaiter};
20
21/// Default navigation timeout.
22const DEFAULT_NAVIGATION_TIMEOUT: Duration = Duration::from_secs(30);
23
24/// Internal frame data that can be updated.
25#[derive(Debug, Clone)]
26struct FrameData {
27    /// Frame's current URL.
28    url: String,
29    /// Frame's name attribute.
30    name: String,
31    /// Whether the frame is detached.
32    detached: bool,
33}
34
35/// A frame within a page.
36///
37/// Frames are separate browsing contexts, typically created by `<iframe>` elements.
38/// Each frame has its own DOM and JavaScript execution context.
39#[derive(Debug)]
40pub struct Frame {
41    /// CDP connection.
42    connection: Arc<CdpConnection>,
43    /// Session ID for this frame's page.
44    session_id: String,
45    /// Unique frame identifier.
46    id: String,
47    /// Parent frame ID (None for main frame).
48    parent_id: Option<String>,
49    /// Loader ID for this frame.
50    loader_id: String,
51    /// Mutable frame data.
52    data: RwLock<FrameData>,
53}
54
55impl Frame {
56    /// Create a new frame from CDP frame info.
57    pub(crate) fn new(
58        connection: Arc<CdpConnection>,
59        session_id: String,
60        id: String,
61        parent_id: Option<String>,
62        loader_id: String,
63        url: String,
64        name: String,
65    ) -> Self {
66        Self {
67            connection,
68            session_id,
69            id,
70            parent_id,
71            loader_id,
72            data: RwLock::new(FrameData {
73                url,
74                name,
75                detached: false,
76            }),
77        }
78    }
79
80    /// Get the unique frame identifier.
81    pub fn id(&self) -> &str {
82        &self.id
83    }
84
85    /// Get the parent frame ID.
86    ///
87    /// Returns `None` for the main frame.
88    pub fn parent_id(&self) -> Option<&str> {
89        self.parent_id.as_deref()
90    }
91
92    /// Check if this is the main frame.
93    pub fn is_main(&self) -> bool {
94        self.parent_id.is_none()
95    }
96
97    /// Get the loader ID.
98    pub fn loader_id(&self) -> &str {
99        &self.loader_id
100    }
101
102    /// Get the frame's current URL.
103    pub fn url(&self) -> String {
104        self.data.read().url.clone()
105    }
106
107    /// Get the frame's name attribute.
108    pub fn name(&self) -> String {
109        self.data.read().name.clone()
110    }
111
112    /// Check if the frame has been detached.
113    pub fn is_detached(&self) -> bool {
114        self.data.read().detached
115    }
116
117    /// Update the frame's URL (called when frame navigates).
118    pub(crate) fn set_url(&self, url: String) {
119        self.data.write().url = url;
120    }
121
122    /// Update the frame's name.
123    pub(crate) fn set_name(&self, name: String) {
124        self.data.write().name = name;
125    }
126
127    /// Mark the frame as detached.
128    pub(crate) fn set_detached(&self) {
129        self.data.write().detached = true;
130    }
131
132    /// Get the frame's HTML content.
133    ///
134    /// # Errors
135    ///
136    /// Returns an error if the frame is detached or the evaluation fails.
137    #[instrument(level = "debug", skip(self), fields(frame_id = %self.id))]
138    pub async fn content(&self) -> Result<String, PageError> {
139        if self.is_detached() {
140            return Err(PageError::EvaluationFailed("Frame is detached".to_string()));
141        }
142
143        let result: viewpoint_cdp::protocol::runtime::EvaluateResult = self
144            .connection
145            .send_command(
146                "Runtime.evaluate",
147                Some(EvaluateParams {
148                    expression: "document.documentElement.outerHTML".to_string(),
149                    object_group: None,
150                    include_command_line_api: None,
151                    silent: Some(true),
152                    context_id: None, // TODO: Use frame's execution context
153                    return_by_value: Some(true),
154                    await_promise: Some(false),
155                }),
156                Some(&self.session_id),
157            )
158            .await?;
159
160        result
161            .result
162            .value
163            .and_then(|v| v.as_str().map(ToString::to_string))
164            .ok_or_else(|| PageError::EvaluationFailed("Failed to get content".to_string()))
165    }
166
167    /// Get the frame's document title.
168    ///
169    /// # Errors
170    ///
171    /// Returns an error if the frame is detached or the evaluation fails.
172    #[instrument(level = "debug", skip(self), fields(frame_id = %self.id))]
173    pub async fn title(&self) -> Result<String, PageError> {
174        if self.is_detached() {
175            return Err(PageError::EvaluationFailed("Frame is detached".to_string()));
176        }
177
178        let result: viewpoint_cdp::protocol::runtime::EvaluateResult = self
179            .connection
180            .send_command(
181                "Runtime.evaluate",
182                Some(EvaluateParams {
183                    expression: "document.title".to_string(),
184                    object_group: None,
185                    include_command_line_api: None,
186                    silent: Some(true),
187                    context_id: None, // TODO: Use frame's execution context
188                    return_by_value: Some(true),
189                    await_promise: Some(false),
190                }),
191                Some(&self.session_id),
192            )
193            .await?;
194
195        result
196            .result
197            .value
198            .and_then(|v| v.as_str().map(ToString::to_string))
199            .ok_or_else(|| PageError::EvaluationFailed("Failed to get title".to_string()))
200    }
201
202    /// Navigate the frame to a URL.
203    ///
204    /// # Errors
205    ///
206    /// Returns an error if the frame is detached or navigation fails.
207    #[instrument(level = "info", skip(self), fields(frame_id = %self.id, url = %url))]
208    pub async fn goto(&self, url: &str) -> Result<(), NavigationError> {
209        self.goto_with_options(url, DocumentLoadState::Load, DEFAULT_NAVIGATION_TIMEOUT)
210            .await
211    }
212
213    /// Navigate the frame to a URL with options.
214    ///
215    /// # Errors
216    ///
217    /// Returns an error if the frame is detached or navigation fails.
218    #[instrument(level = "info", skip(self), fields(frame_id = %self.id, url = %url, wait_until = ?wait_until))]
219    pub async fn goto_with_options(
220        &self,
221        url: &str,
222        wait_until: DocumentLoadState,
223        timeout: Duration,
224    ) -> Result<(), NavigationError> {
225        if self.is_detached() {
226            return Err(NavigationError::Cancelled);
227        }
228
229        info!("Navigating frame to URL");
230
231        // Create a load state waiter
232        let event_rx = self.connection.subscribe_events();
233        let mut waiter = LoadStateWaiter::new(event_rx, self.session_id.clone(), self.id.clone());
234
235        // Send navigation command with frame_id
236        debug!("Sending Page.navigate command for frame");
237        let result: NavigateResult = self
238            .connection
239            .send_command(
240                "Page.navigate",
241                Some(NavigateParams {
242                    url: url.to_string(),
243                    referrer: None,
244                    transition_type: None,
245                    frame_id: Some(self.id.clone()),
246                }),
247                Some(&self.session_id),
248            )
249            .await?;
250
251        debug!(frame_id = %result.frame_id, "Page.navigate completed for frame");
252
253        // Check for navigation errors
254        if let Some(error_text) = result.error_text {
255            return Err(NavigationError::NetworkError(error_text));
256        }
257
258        // Mark commit as received
259        waiter.set_commit_received().await;
260
261        // Wait for the target load state
262        debug!(wait_until = ?wait_until, "Waiting for load state");
263        waiter
264            .wait_for_load_state_with_timeout(wait_until, timeout)
265            .await?;
266
267        // Update the frame's URL
268        self.set_url(url.to_string());
269
270        info!(frame_id = %self.id, "Frame navigation completed");
271        Ok(())
272    }
273
274    /// Set the frame's HTML content.
275    ///
276    /// # Errors
277    ///
278    /// Returns an error if the frame is detached or setting content fails.
279    #[instrument(level = "info", skip(self, html), fields(frame_id = %self.id))]
280    pub async fn set_content(&self, html: &str) -> Result<(), PageError> {
281        if self.is_detached() {
282            return Err(PageError::EvaluationFailed("Frame is detached".to_string()));
283        }
284
285        use viewpoint_cdp::protocol::page::SetDocumentContentParams;
286
287        self.connection
288            .send_command::<_, serde_json::Value>(
289                "Page.setDocumentContent",
290                Some(SetDocumentContentParams {
291                    frame_id: self.id.clone(),
292                    html: html.to_string(),
293                }),
294                Some(&self.session_id),
295            )
296            .await?;
297
298        info!("Frame content set");
299        Ok(())
300    }
301
302    /// Wait for the frame to reach a specific load state.
303    ///
304    /// # Errors
305    ///
306    /// Returns an error if the wait times out or the frame is detached.
307    #[instrument(level = "debug", skip(self), fields(frame_id = %self.id, state = ?state))]
308    pub async fn wait_for_load_state(&self, state: DocumentLoadState) -> Result<(), NavigationError> {
309        self.wait_for_load_state_with_timeout(state, DEFAULT_NAVIGATION_TIMEOUT)
310            .await
311    }
312
313    /// Wait for the frame to reach a specific load state with timeout.
314    ///
315    /// # Errors
316    ///
317    /// Returns an error if the wait times out or the frame is detached.
318    #[instrument(level = "debug", skip(self), fields(frame_id = %self.id, state = ?state, timeout_ms = timeout.as_millis()))]
319    pub async fn wait_for_load_state_with_timeout(
320        &self,
321        state: DocumentLoadState,
322        timeout: Duration,
323    ) -> Result<(), NavigationError> {
324        if self.is_detached() {
325            return Err(NavigationError::Cancelled);
326        }
327
328        let event_rx = self.connection.subscribe_events();
329        let mut waiter = LoadStateWaiter::new(event_rx, self.session_id.clone(), self.id.clone());
330
331        // Assume commit already happened for existing frames
332        waiter.set_commit_received().await;
333
334        waiter.wait_for_load_state_with_timeout(state, timeout).await?;
335
336        debug!("Frame reached load state {:?}", state);
337        Ok(())
338    }
339
340    /// Get the session ID.
341    pub(crate) fn session_id(&self) -> &str {
342        &self.session_id
343    }
344
345    /// Get the connection.
346    pub(crate) fn connection(&self) -> &Arc<CdpConnection> {
347        &self.connection
348    }
349
350    /// Get child frames of this frame.
351    ///
352    /// Returns a list of frames that are direct children of this frame.
353    ///
354    /// # Errors
355    ///
356    /// Returns an error if querying the frame tree fails.
357    #[instrument(level = "debug", skip(self), fields(frame_id = %self.id))]
358    pub async fn child_frames(&self) -> Result<Vec<Frame>, PageError> {
359        if self.is_detached() {
360            return Err(PageError::EvaluationFailed("Frame is detached".to_string()));
361        }
362
363        // Get the frame tree from CDP
364        let result: viewpoint_cdp::protocol::page::GetFrameTreeResult = self
365            .connection
366            .send_command("Page.getFrameTree", None::<()>, Some(&self.session_id))
367            .await?;
368
369        // Find this frame in the tree and return its children
370        let children = find_child_frames(
371            &result.frame_tree,
372            &self.id,
373            &self.connection,
374            &self.session_id,
375        );
376
377        Ok(children)
378    }
379
380    /// Get the parent frame.
381    ///
382    /// Returns `None` if this is the main frame.
383    ///
384    /// # Errors
385    ///
386    /// Returns an error if querying the frame tree fails.
387    #[instrument(level = "debug", skip(self), fields(frame_id = %self.id))]
388    pub async fn parent_frame(&self) -> Result<Option<Frame>, PageError> {
389        if self.is_detached() {
390            return Err(PageError::EvaluationFailed("Frame is detached".to_string()));
391        }
392
393        // Main frame has no parent
394        if self.is_main() {
395            return Ok(None);
396        }
397
398        // Get the frame tree from CDP
399        let result: viewpoint_cdp::protocol::page::GetFrameTreeResult = self
400            .connection
401            .send_command("Page.getFrameTree", None::<()>, Some(&self.session_id))
402            .await?;
403
404        // Find the parent frame
405        let parent = find_parent_frame(
406            &result.frame_tree,
407            &self.id,
408            &self.connection,
409            &self.session_id,
410        );
411
412        Ok(parent)
413    }
414}
415
416/// Recursively find child frames of a given frame ID.
417fn find_child_frames(
418    tree: &viewpoint_cdp::protocol::page::FrameTree,
419    parent_id: &str,
420    connection: &Arc<CdpConnection>,
421    session_id: &str,
422) -> Vec<Frame> {
423    let mut children = Vec::new();
424
425    // Check if this is the parent we're looking for
426    if tree.frame.id == parent_id {
427        // Return all direct children
428        if let Some(ref child_frames) = tree.child_frames {
429            for child in child_frames {
430                children.push(Frame::new(
431                    connection.clone(),
432                    session_id.to_string(),
433                    child.frame.id.clone(),
434                    Some(parent_id.to_string()),
435                    child.frame.loader_id.clone(),
436                    child.frame.url.clone(),
437                    child.frame.name.clone().unwrap_or_default(),
438                ));
439            }
440        }
441    } else {
442        // Recurse into children to find the parent
443        if let Some(ref child_frames) = tree.child_frames {
444            for child in child_frames {
445                let found = find_child_frames(child, parent_id, connection, session_id);
446                children.extend(found);
447            }
448        }
449    }
450
451    children
452}
453
454/// Recursively find the parent frame of a given frame ID.
455fn find_parent_frame(
456    tree: &viewpoint_cdp::protocol::page::FrameTree,
457    frame_id: &str,
458    connection: &Arc<CdpConnection>,
459    session_id: &str,
460) -> Option<Frame> {
461    // Check if any direct child is the frame we're looking for
462    if let Some(ref child_frames) = tree.child_frames {
463        for child in child_frames {
464            if child.frame.id == frame_id {
465                // Found it - return the current frame as the parent
466                return Some(Frame::new(
467                    connection.clone(),
468                    session_id.to_string(),
469                    tree.frame.id.clone(),
470                    tree.frame.parent_id.clone(),
471                    tree.frame.loader_id.clone(),
472                    tree.frame.url.clone(),
473                    tree.frame.name.clone().unwrap_or_default(),
474                ));
475            }
476        }
477
478        // Recurse into children
479        for child in child_frames {
480            if let Some(parent) = find_parent_frame(child, frame_id, connection, session_id) {
481                return Some(parent);
482            }
483        }
484    }
485
486    None
487}
488
489