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