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