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}