1use super::*;
7
8impl BrowserSession {
9 pub async fn click_expect_popup(&self, target: &str) -> BrowserResult<PopupClickOutcome> {
16 self.click_expect_popup_with_revision(target, None).await
17 }
18
19 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 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 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}