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}