1use super::*;
7
8impl BrowserSession {
9 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 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 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}