Skip to main content

glass/browser/session/
popup.rs

1//! Popup window click and witness tracking.
2//!
3//! Clicks a target that is expected to open a popup window, then verifies
4//! the causal relationship by tracking CDP target creation events.
5
6use super::*;
7
8impl BrowserSession {
9    /// Click a target that is expected to open a popup window.
10    ///
11    /// Monitors target creation events during the click, identifies the new
12    /// popup target, and returns both the click outcome and the popup's
13    /// [`PageTargetInfo`]. If no popup appears within the witness window,
14    /// returns a [`PopupClickError`].
15    pub async fn click_expect_popup(&self, target: &str) -> BrowserResult<PopupClickOutcome> {
16        let _scope = self.popup_click_scope.lock().await;
17        self.cdp
18            .with_current_route(async {
19                let element = self.resolve_element(target).await?;
20                let object_id = self
21                    .cdp
22                    .resolve_node_object(element.node_id, element.backend_dom_node_id)
23                    .await
24                    .map_err(|error| {
25                        tracing::debug!(%error, "popup target node could not be resolved");
26                        TargetError {
27                            kind: TargetErrorKind::NotActionable,
28                            reason: Some(TargetActionabilityReason::NodeUnavailable),
29                            candidates: Vec::new(),
30                            recovery: None,
31                        }
32                    })?;
33                let remote = RemoteObjectGuard::new(self.cdp.clone(), object_id);
34                let original_session_id = self
35                    .cdp
36                    .current_session_id()
37                    .ok_or_else(|| {
38                        TopologyError::new(
39                            TopologyErrorKind::NoPageSession,
40                            "popup click requires an attached page session; the session may need to be re-established",
41                        )
42                    })?;
43                let original_frame_id = self
44                    .cdp
45                    .active_frame()
46                    .ok_or_else(|| {
47                        TopologyError::new(
48                            TopologyErrorKind::StaleFrame,
49                            "popup click requires an active frame; call listFrames to discover available frames",
50                        )
51                    })?;
52                let backend_node_id = match (element.backend_dom_node_id, element.node_id) {
53                    (Some(backend_node_id), _) => backend_node_id,
54                    (None, Some(node_id)) => self
55                        .cdp
56                        .backend_node_id_for_node(node_id)
57                        .await
58                        .map_err(|error| {
59                            popup_error(
60                                PopupClickErrorKind::WitnessMissing,
61                                format!(
62                                    "resolved popup target has no readable backend identity: {error}"
63                                ),
64                            )
65                        })?,
66                    (None, None) => {
67                        return Err(popup_error(
68                            PopupClickErrorKind::WitnessMissing,
69                            "popup target has no exact node identity",
70                        ));
71                    }
72                };
73                let mut witness = self
74                    .arm_popup_witness(&original_session_id, &original_frame_id, backend_node_id)
75                    .await?;
76                let operation = self
77                    .perform_popup_click(&remote.object_id, &element, &mut witness)
78                    .await;
79                let cleanup = witness.cleanup().await;
80                match (operation, cleanup) {
81                    (Ok(outcome), Ok(())) => Ok(outcome),
82                    (Err(error), _) => Err(error),
83                    (Ok(_), Err(error)) => Err(error),
84                }
85            })
86            .await
87    }
88
89    async fn arm_popup_witness(
90        &self,
91        session_id: &str,
92        frame_id: &str,
93        backend_node_id: i64,
94    ) -> BrowserResult<PopupWitnessGuard> {
95        let cdp = self.cdp.clone();
96        let session_id = session_id.to_string();
97        let frame_id = frame_id.to_string();
98        let (sender, receiver) = tokio::sync::oneshot::channel();
99        tokio::spawn(async move {
100            let result = arm_popup_witness_owned(cdp, session_id, frame_id, backend_node_id).await;
101            let _ = sender.send(result);
102        });
103        receiver
104            .await
105            .map_err(|_| {
106                popup_error(
107                    PopupClickErrorKind::WitnessMissing,
108                    "popup witness worker ended without a result",
109                )
110            })?
111            .map_err(Into::into)
112    }
113
114    async fn perform_popup_click(
115        &self,
116        object_id: &str,
117        element: &ResolvedElement,
118        witness: &mut PopupWitnessGuard,
119    ) -> BrowserResult<PopupClickOutcome> {
120        let local_point = self.verified_action_point(object_id).await?;
121        let point = self.target_viewport_point(local_point).await?;
122        let mut pointer = self.pointer.lock().await;
123        let start = match (self.interaction_mode, *pointer) {
124            (_, Some(point)) => point,
125            (InteractionMode::Human, None) => self
126                .viewport_center()
127                .await
128                .unwrap_or(Point { x: 640.0, y: 360.0 }),
129            (InteractionMode::Fast, None) => point,
130        };
131        let path = interaction_path(self.interaction_mode, &self.mouse, start, point);
132        if self.interaction_mode == InteractionMode::Human && pointer.is_none() {
133            self.cdp
134                .dispatch_mouse_event("mouseMoved", start.x, start.y, None, None)
135                .await?;
136        }
137        for window in path.windows(2) {
138            let next = window[1];
139            if self.interaction_mode == InteractionMode::Human {
140                tokio::time::sleep(self.mouse.move_delay(window[0], next)).await;
141            }
142            self.cdp
143                .dispatch_mouse_event("mouseMoved", next.x, next.y, None, None)
144                .await?;
145        }
146        let press_point = self.verified_action_point(object_id).await?;
147        if (press_point.x - local_point.x).abs() > 1.0
148            || (press_point.y - local_point.y).abs() > 1.0
149        {
150            return Err(TargetError {
151                kind: TargetErrorKind::NotActionable,
152                reason: Some(TargetActionabilityReason::GeometryChanged),
153                candidates: Vec::new(),
154                recovery: None,
155            }
156            .into());
157        }
158        self.cdp
159            .dispatch_mouse_event("mousePressed", point.x, point.y, Some("left"), Some(1))
160            .await?;
161        let mut pressed = PressedButtonGuard {
162            cdp: self.cdp.clone(),
163            point,
164            click_count: 1,
165            armed: true,
166        };
167        if self.interaction_mode == InteractionMode::Human {
168            tokio::time::sleep(self.mouse.click_delay()).await;
169        }
170
171        // This snapshot is intentionally adjacent to and before the release.
172        let snapshot = self.popup_topology_snapshot().await?;
173        let release_started = std::time::Instant::now();
174        let release = self
175            .cdp
176            .dispatch_mouse_event_with_timeout(
177                "mouseReleased",
178                point.x,
179                point.y,
180                Some("left"),
181                Some(1),
182                POPUP_RELEASE_ACK_TIMEOUT,
183            )
184            .await;
185        let release_ack_wait_ms = release_started.elapsed().as_secs_f64() * 1_000.0;
186        let release_acknowledged = match release {
187            Ok(_) => true,
188            Err(error) if error.is_response_timeout() => false,
189            Err(error) => {
190                return Err(popup_error(
191                    PopupClickErrorKind::ReleaseFailed,
192                    format!("mouseReleased failed without a response timeout: {error}"),
193                ));
194            }
195        };
196        // The release was either acknowledged or causally witnessed. Never emit
197        // the guard's second, fire-and-forget release for the timeout case.
198        pressed.armed = false;
199
200        let evidence_deadline = tokio::time::Instant::now() + POPUP_EVIDENCE_DEADLINE;
201        let candidate = self
202            .wait_for_causal_popup(&snapshot, witness, evidence_deadline)
203            .await?;
204        let ready_state = self
205            .verify_popup_readiness(&snapshot, &candidate, evidence_deadline)
206            .await?;
207        *pointer = Some(point);
208        Ok(PopupClickOutcome {
209            action: ActionKind::ClickExpectPopup,
210            target: ActionTarget {
211                label: element.label.clone(),
212                reference: element.reference.clone(),
213            },
214            revision: self.invalidate_observation(),
215            target_id: snapshot.original_target_id.clone(),
216            frame_id: snapshot.original_frame_id.clone(),
217            causally_verified_popup: true,
218            popup_id: candidate.target.id.clone(),
219            opener_id: snapshot.original_target_id.clone(),
220            evidence: PopupVerificationEvidence {
221                trusted_click_witness: true,
222                release_acknowledged,
223                release_ack_wait_ms,
224                topology_sequence_before_release: snapshot.sequence,
225                popup_observed_sequence: candidate.observed_sequence,
226                attached: true,
227                ready_state,
228            },
229        })
230    }
231
232    async fn popup_topology_snapshot(&self) -> BrowserResult<PopupTopologySnapshot> {
233        let raw_targets = popup_verification_call(
234            self.cdp.send_browser("Target.getTargets", None),
235            "pre-release target snapshot",
236        )
237        .await?;
238        let mut preexisting_target_ids = HashSet::new();
239        for info in raw_targets["targetInfos"].as_array().into_iter().flatten() {
240            if info["type"].as_str() != Some("page") {
241                continue;
242            }
243            let id = info["targetId"].as_str().ok_or_else(|| {
244                popup_error(
245                    PopupClickErrorKind::PopupUnreadable,
246                    "pre-release target snapshot contained a page without an ID",
247                )
248            })?;
249            validate_topology_id(id)?;
250            preexisting_target_ids.insert(id.to_string());
251        }
252        let topology = self.topology.lock().await;
253        let original_target_id = topology.active_target_id.clone().ok_or_else(|| {
254            TopologyError::new(
255                TopologyErrorKind::NoTargetSelected,
256                "popup click has no active target; call listTargets to discover available pages",
257            )
258        })?;
259        let original_frame_id = topology.active_frame_id.clone().ok_or_else(|| {
260            TopologyError::new(
261                TopologyErrorKind::StaleFrame,
262                "popup click has no active frame; call listFrames to discover available frames",
263            )
264        })?;
265        Ok(PopupTopologySnapshot {
266            original_target_id,
267            original_frame_id,
268            preexisting_target_ids,
269            sequence: topology.sequence,
270            event_loss_count: topology.event_loss_count,
271        })
272    }
273
274    async fn wait_for_causal_popup(
275        &self,
276        snapshot: &PopupTopologySnapshot,
277        witness: &PopupWitnessGuard,
278        deadline: tokio::time::Instant,
279    ) -> BrowserResult<PopupCandidate> {
280        let mut witnessed = false;
281        loop {
282            if !witnessed {
283                witnessed = witness.fired().await?;
284            }
285
286            let assessment = {
287                let topology = self.topology.lock().await;
288                assess_popup_topology(snapshot, &topology, witnessed)
289            };
290            match assessment {
291                Ok(candidate) => {
292                    return wait_for_stable_popup_topology(
293                        &self.topology,
294                        snapshot,
295                        &candidate,
296                        deadline,
297                        POPUP_TOPOLOGY_QUIET_INTERVAL,
298                    )
299                    .await
300                    .map_err(Into::into);
301                }
302                Err(error)
303                    if matches!(
304                        error.kind,
305                        PopupClickErrorKind::TopologyLagged
306                            | PopupClickErrorKind::PopupAmbiguous
307                            | PopupClickErrorKind::PopupDestroyed
308                    ) =>
309                {
310                    return Err(error.into());
311                }
312                Err(error) if tokio::time::Instant::now() >= deadline => {
313                    return Err(error.into());
314                }
315                Err(_) => tokio::time::sleep(Duration::from_millis(10)).await,
316            }
317        }
318    }
319
320    async fn verify_popup_readiness(
321        &self,
322        snapshot: &PopupTopologySnapshot,
323        candidate: &PopupCandidate,
324        deadline: tokio::time::Instant,
325    ) -> BrowserResult<String> {
326        let mut attachment = self.attach_popup(&candidate.target.id).await?;
327        popup_verification_call(
328            self.cdp.send_to_session(
329                &attachment.session_id,
330                "Runtime.runIfWaitingForDebugger",
331                None,
332            ),
333            "popup debugger resume",
334        )
335        .await?;
336        let result = popup_verification_call(
337            self.cdp.send_to_session(
338                &attachment.session_id,
339                "Runtime.evaluate",
340                Some(serde_json::json!({
341                    "expression": "document.readyState",
342                    "returnByValue": true,
343                    "awaitPromise": false
344                })),
345            ),
346            "popup readiness evaluation",
347        )
348        .await?;
349        let ready_state = result["result"]["value"]
350            .as_str()
351            .filter(|state| matches!(*state, "loading" | "interactive" | "complete"))
352            .map(str::to_string)
353            .ok_or_else(|| {
354                popup_error(
355                    PopupClickErrorKind::PopupUnreadable,
356                    "popup returned no valid document.readyState",
357                )
358            })?;
359        self.final_popup_verification(snapshot, candidate, deadline)
360            .await?;
361        attachment.detach().await?;
362        Ok(ready_state)
363    }
364
365    pub(crate) async fn attach_popup(
366        &self,
367        target_id: &str,
368    ) -> BrowserResult<PopupAttachmentGuard> {
369        let cdp = self.cdp.clone();
370        let target_id = target_id.to_string();
371        let (sender, receiver) = tokio::sync::oneshot::channel();
372        tokio::spawn(async move {
373            let result = match tokio::time::timeout(
374                POPUP_VERIFY_CALL_TIMEOUT,
375                cdp.send_browser(
376                    "Target.attachToTarget",
377                    Some(serde_json::json!({"targetId": target_id, "flatten": true})),
378                ),
379            )
380            .await
381            {
382                Ok(Ok(value)) => value["sessionId"]
383                    .as_str()
384                    .map(|session_id| PopupAttachmentGuard {
385                        cdp: cdp.clone(),
386                        session_id: session_id.to_string(),
387                        armed: true,
388                    })
389                    .ok_or_else(|| {
390                        popup_typed_error(
391                            PopupClickErrorKind::PopupUnreadable,
392                            "popup attach returned no session ID",
393                        )
394                    }),
395                Ok(Err(error)) => Err(popup_typed_error(
396                    PopupClickErrorKind::PopupUnreadable,
397                    format!("popup attach failed: {error}"),
398                )),
399                Err(_) => Err(popup_typed_error(
400                    PopupClickErrorKind::PopupUnreadable,
401                    "popup attach exceeded its bounded deadline",
402                )),
403            };
404            let _ = sender.send(result);
405        });
406        receiver
407            .await
408            .map_err(|_| {
409                popup_error(
410                    PopupClickErrorKind::PopupUnreadable,
411                    "popup attach worker ended without a result",
412                )
413            })?
414            .map_err(Into::into)
415    }
416
417    pub(crate) async fn final_popup_verification(
418        &self,
419        snapshot: &PopupTopologySnapshot,
420        candidate: &PopupCandidate,
421        deadline: tokio::time::Instant,
422    ) -> BrowserResult<()> {
423        loop {
424            if tokio::time::Instant::now() >= deadline {
425                return Err(popup_error(
426                    PopupClickErrorKind::TopologyLagged,
427                    "popup topology did not settle before final verification deadline",
428                ));
429            }
430            let (stable_sequence, stable_loss) = {
431                let topology = self.topology.lock().await;
432                let current = assess_popup_topology(snapshot, &topology, true)?;
433                if current.target.id != candidate.target.id {
434                    return Err(popup_error(
435                        PopupClickErrorKind::PopupAmbiguous,
436                        "popup candidate changed during readiness verification",
437                    ));
438                }
439                (topology.sequence, topology.event_loss_count)
440            };
441            let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
442            if remaining.is_zero() {
443                return Err(popup_error(
444                    PopupClickErrorKind::TopologyLagged,
445                    "popup topology deadline expired before authoritative discovery",
446                ));
447            }
448            let targets = match tokio::time::timeout(
449                remaining,
450                self.cdp.send_browser("Target.getTargets", None),
451            )
452            .await
453            {
454                Ok(Ok(targets)) => targets,
455                Ok(Err(error)) => {
456                    return Err(popup_error(
457                        PopupClickErrorKind::PopupUnreadable,
458                        format!("final authoritative popup target discovery failed: {error}"),
459                    ));
460                }
461                Err(_) => {
462                    return Err(popup_error(
463                        PopupClickErrorKind::TopologyLagged,
464                        "final authoritative popup target discovery exceeded the evidence deadline",
465                    ));
466                }
467            };
468            let mut matches = Vec::new();
469            for info in targets["targetInfos"].as_array().ok_or_else(|| {
470                popup_error(
471                    PopupClickErrorKind::PopupUnreadable,
472                    "final target discovery returned no target list",
473                )
474            })? {
475                if info["type"].as_str() != Some("page") {
476                    continue;
477                }
478                let id = info["targetId"].as_str().ok_or_else(|| {
479                    popup_error(
480                        PopupClickErrorKind::PopupUnreadable,
481                        "final target discovery contained a page without an ID",
482                    )
483                })?;
484                validate_topology_id(id)?;
485                if !snapshot.preexisting_target_ids.contains(id)
486                    && info["openerId"].as_str() == Some(snapshot.original_target_id.as_str())
487                {
488                    matches.push(id);
489                }
490            }
491            if matches.len() != 1 || matches[0] != candidate.target.id {
492                return Err(popup_error(
493                    if matches.len() > 1 {
494                        PopupClickErrorKind::PopupAmbiguous
495                    } else {
496                        PopupClickErrorKind::PopupDestroyed
497                    },
498                    format!(
499                        "final target discovery found {} live later opener matches",
500                        matches.len()
501                    ),
502                ));
503            }
504            let topology = self.topology.lock().await;
505            if topology.event_loss_count != stable_loss {
506                return Err(popup_error(
507                    PopupClickErrorKind::TopologyLagged,
508                    "popup topology event loss changed during final verification",
509                ));
510            }
511            let current = assess_popup_topology(snapshot, &topology, true)?;
512            if current.target.id != candidate.target.id {
513                return Err(popup_error(
514                    PopupClickErrorKind::PopupAmbiguous,
515                    "popup candidate changed at final topology verification",
516                ));
517            }
518            if topology.sequence == stable_sequence {
519                if tokio::time::Instant::now() >= deadline {
520                    return Err(popup_error(
521                        PopupClickErrorKind::TopologyLagged,
522                        "popup topology deadline expired before final success",
523                    ));
524                }
525                return Ok(());
526            }
527            drop(topology);
528            wait_for_stable_popup_topology(
529                &self.topology,
530                snapshot,
531                candidate,
532                deadline,
533                POPUP_TOPOLOGY_QUIET_INTERVAL,
534            )
535            .await?;
536        }
537    }
538}