viewpoint_core/page/popup/
mod.rs

1//! Popup window handling.
2//!
3//! This module provides functionality for detecting and handling popup windows
4//! that are opened by JavaScript code (e.g., via `window.open()`).
5
6// Allow dead code for popup scaffolding (spec: page-operations)
7
8use std::future::Future;
9use std::pin::Pin;
10use std::sync::Arc;
11use std::time::Duration;
12
13use tokio::sync::{oneshot, RwLock};
14use tracing::debug;
15
16use viewpoint_cdp::CdpConnection;
17
18use crate::error::PageError;
19use crate::page::Page;
20
21/// Type alias for the popup event handler function.
22pub type PopupEventHandler = Box<
23    dyn Fn(Page) -> Pin<Box<dyn Future<Output = ()> + Send>> + Send + Sync,
24>;
25
26/// Manager for page-level popup events.
27pub struct PopupManager {
28    /// Popup event handler.
29    handler: RwLock<Option<PopupEventHandler>>,
30    /// Target ID of the owning page.
31    target_id: String,
32    /// CDP connection for subscribing to events.
33    connection: Arc<CdpConnection>,
34    /// Session ID of the owning page.
35    session_id: String,
36}
37
38impl PopupManager {
39    /// Create a new popup manager for a page.
40    pub fn new(connection: Arc<CdpConnection>, session_id: String, target_id: String) -> Self {
41        Self {
42            handler: RwLock::new(None),
43            target_id,
44            connection,
45            session_id,
46        }
47    }
48
49    /// Set a handler for popup events.
50    pub async fn set_handler<F, Fut>(&self, handler: F)
51    where
52        F: Fn(Page) -> Fut + Send + Sync + 'static,
53        Fut: Future<Output = ()> + Send + 'static,
54    {
55        let boxed_handler: PopupEventHandler = Box::new(move |page| {
56            Box::pin(handler(page))
57        });
58        let mut h = self.handler.write().await;
59        *h = Some(boxed_handler);
60    }
61
62    /// Remove the popup handler.
63    pub async fn remove_handler(&self) {
64        let mut h = self.handler.write().await;
65        *h = None;
66    }
67
68    /// Emit a popup event to the handler.
69    pub async fn emit(&self, popup: Page) {
70        let handler = self.handler.read().await;
71        if let Some(ref h) = *handler {
72            h(popup).await;
73        }
74    }
75
76    /// Check if this popup was opened by the page with the given `target_id`.
77    pub fn is_opener(&self, opener_id: &str) -> bool {
78        self.target_id == opener_id
79    }
80}
81
82/// Builder for waiting for a popup during an action.
83pub struct WaitForPopupBuilder<'a, F, Fut>
84where
85    F: FnOnce() -> Fut,
86    Fut: Future<Output = Result<(), crate::error::LocatorError>>,
87{
88    page: &'a Page,
89    action: Option<F>,
90    timeout: Duration,
91}
92
93// =========================================================================
94// Page impl - Popup Handling Methods
95// =========================================================================
96
97impl Page {
98    /// Set a handler for popup window events.
99    ///
100    /// The handler will be called whenever a popup window is opened
101    /// from this page (e.g., via `window.open()` or `target="_blank"` links).
102    ///
103    /// # Example
104    ///
105    /// ```no_run
106    /// use viewpoint_core::page::Page;
107    ///
108    /// # async fn example(page: &Page) -> Result<(), viewpoint_core::CoreError> {
109    /// page.on_popup(|mut popup| async move {
110    ///     println!("Popup opened: {}", popup.url().await.unwrap_or_default());
111    ///     // Work with the popup
112    ///     let _ = popup.close().await;
113    /// }).await;
114    /// # Ok(())
115    /// # }
116    /// ```
117    pub async fn on_popup<F, Fut>(&self, handler: F)
118    where
119        F: Fn(Page) -> Fut + Send + Sync + 'static,
120        Fut: Future<Output = ()> + Send + 'static,
121    {
122        self.popup_manager.set_handler(handler).await;
123    }
124
125    /// Remove the popup handler.
126    pub async fn off_popup(&self) {
127        self.popup_manager.remove_handler().await;
128    }
129
130    /// Wait for a popup to be opened during an action.
131    ///
132    /// This is useful for handling popups that are opened by clicking links
133    /// or buttons that open new windows.
134    ///
135    /// # Example
136    ///
137    /// ```no_run
138    /// use viewpoint_core::page::Page;
139    ///
140    /// # async fn example(page: &Page) -> Result<(), viewpoint_core::CoreError> {
141    /// let mut popup = page.wait_for_popup(|| async {
142    ///     page.locator("a[target=_blank]").click().await
143    /// }).wait().await?;
144    ///
145    /// // Now work with the popup page
146    /// println!("Popup URL: {}", popup.url().await?);
147    /// popup.close().await?;
148    /// # Ok(())
149    /// # }
150    /// ```
151    ///
152    /// # Errors
153    ///
154    /// Returns an error if:
155    /// - The action fails
156    /// - No popup is opened within the timeout (30 seconds by default)
157    pub fn wait_for_popup<F, Fut>(
158        &self,
159        action: F,
160    ) -> WaitForPopupBuilder<'_, F, Fut>
161    where
162        F: FnOnce() -> Fut,
163        Fut: Future<Output = Result<(), crate::error::LocatorError>>,
164    {
165        WaitForPopupBuilder::new(self, action)
166    }
167
168    /// Get the opener page that opened this popup.
169    ///
170    /// Returns `None` if this page is not a popup (was created via `context.new_page()`).
171    ///
172    /// Note: This method currently returns `None` because tracking opener pages
173    /// requires context-level state management. For now, you can check if a page
174    /// is a popup by examining whether it was returned from `wait_for_popup()`.
175    pub fn opener(&self) -> Option<&str> {
176        self.opener_target_id.as_deref()
177    }
178}
179
180impl<'a, F, Fut> WaitForPopupBuilder<'a, F, Fut>
181where
182    F: FnOnce() -> Fut,
183    Fut: Future<Output = Result<(), crate::error::LocatorError>>,
184{
185    /// Create a new builder.
186    pub fn new(page: &'a Page, action: F) -> Self {
187        Self {
188            page,
189            action: Some(action),
190            timeout: Duration::from_secs(30),
191        }
192    }
193
194    /// Set the timeout for waiting for the popup.
195    #[must_use]
196    pub fn timeout(mut self, timeout: Duration) -> Self {
197        self.timeout = timeout;
198        self
199    }
200
201    /// Execute the action and wait for a popup.
202    ///
203    /// Returns the popup page that was opened during the action.
204    pub async fn wait(mut self) -> Result<Page, PageError> {
205        use viewpoint_cdp::protocol::target_domain::{AttachToTargetParams, AttachToTargetResult, TargetCreatedEvent};
206
207        let connection = self.page.connection().clone();
208        let target_id = self.page.target_id().to_string();
209        let _session_id = self.page.session_id().to_string();
210        
211        // Create a channel to receive the popup
212        let (tx, rx) = oneshot::channel::<Page>();
213        let tx = Arc::new(tokio::sync::Mutex::new(Some(tx)));
214
215        // Subscribe to target events
216        let mut events = connection.subscribe_events();
217        let tx_clone = tx.clone();
218        let connection_clone = connection.clone();
219        let target_id_clone = target_id.clone();
220
221        // Spawn a task to listen for popup events
222        let popup_listener = tokio::spawn(async move {
223            while let Ok(event) = events.recv().await {
224                if event.method == "Target.targetCreated" {
225                    if let Some(params) = &event.params {
226                        if let Ok(created_event) = serde_json::from_value::<TargetCreatedEvent>(params.clone()) {
227                            let info = &created_event.target_info;
228                            
229                            // Check if this is a popup opened by our page
230                            if info.target_type == "page" 
231                                && info.opener_id.as_deref() == Some(&target_id_clone)
232                            {
233                                debug!("Popup detected: {}", info.target_id);
234                                
235                                // Attach to the popup target
236                                let attach_result: Result<AttachToTargetResult, _> = connection_clone
237                                    .send_command(
238                                        "Target.attachToTarget",
239                                        Some(AttachToTargetParams {
240                                            target_id: info.target_id.clone(),
241                                            flatten: Some(true),
242                                        }),
243                                        None,
244                                    )
245                                    .await;
246
247                                if let Ok(attach) = attach_result {
248                                    // Enable required domains on the popup
249                                    let popup_session = &attach.session_id;
250                                    
251                                    let _ = connection_clone
252                                        .send_command::<(), serde_json::Value>("Page.enable", None, Some(popup_session))
253                                        .await;
254                                    let _ = connection_clone
255                                        .send_command::<(), serde_json::Value>("Network.enable", None, Some(popup_session))
256                                        .await;
257                                    let _ = connection_clone
258                                        .send_command::<(), serde_json::Value>("Runtime.enable", None, Some(popup_session))
259                                        .await;
260
261                                    // Get the main frame ID
262                                    let frame_tree: Result<viewpoint_cdp::protocol::page::GetFrameTreeResult, _> = connection_clone
263                                        .send_command("Page.getFrameTree", None::<()>, Some(popup_session))
264                                        .await;
265
266                                    if let Ok(tree) = frame_tree {
267                                        let frame_id = tree.frame_tree.frame.id;
268                                        
269                                        // Create the popup Page
270                                        let popup = Page::new(
271                                            connection_clone.clone(),
272                                            info.target_id.clone(),
273                                            attach.session_id.clone(),
274                                            frame_id,
275                                        );
276
277                                        // Send the popup
278                                        let mut guard = tx_clone.lock().await;
279                                        if let Some(sender) = guard.take() {
280                                            let _ = sender.send(popup);
281                                            return;
282                                        }
283                                    }
284                                }
285                            }
286                        }
287                    }
288                }
289            }
290        });
291
292        // Execute the action
293        let action = self.action.take().expect("action already consumed");
294        let action_result = action().await;
295
296        // Wait for the popup or timeout
297        let result = match action_result {
298            Ok(()) => {
299                match tokio::time::timeout(self.timeout, rx).await {
300                    Ok(Ok(popup)) => Ok(popup),
301                    Ok(Err(_)) => Err(PageError::EvaluationFailed(
302                        "Popup channel closed unexpectedly".to_string(),
303                    )),
304                    Err(_) => Err(PageError::EvaluationFailed(
305                        format!("wait_for_popup timed out after {:?}", self.timeout),
306                    )),
307                }
308            }
309            Err(e) => Err(PageError::EvaluationFailed(e.to_string())),
310        };
311
312        // Clean up the listener
313        popup_listener.abort();
314
315        result
316    }
317}