1use super::*;
8
9impl BrowserSession {
10 pub async fn click_at(&self, x: f64, y: f64) -> BrowserResult<CoordinateClickOutcome> {
15 self.policy
16 .require(crate::browser::policy::PolicyCapability::CoordinateClick)?;
17 if !x.is_finite() || !y.is_finite() || x < 0.0 || y < 0.0 {
18 return Err("click-at coordinates must be finite and non-negative".into());
19 }
20
21 self.cdp
22 .with_current_route(async {
23 let hit = self
24 .evaluate_value(&format!(
25 "(() => {{ if ({x} >= innerWidth || {y} >= innerHeight) return null; const e = document.elementFromPoint({x}, {y}); if (!e) return null; return {{tag:e.tagName.toLowerCase(), role:e.getAttribute('role'), name:e.getAttribute('aria-label') || e.textContent?.trim().slice(0, 160) || null}}; }})()"
26 ))
27 .await?;
28 let hit = if hit.is_null() {
29 None
30 } else {
31 Some(CoordinateHit {
32 tag: hit["tag"].as_str().unwrap_or("unknown").to_string(),
33 role: hit["role"].as_str().map(str::to_string),
34 name: hit["name"].as_str().map(str::to_string),
35 })
36 };
37 if hit.is_none() {
38 return Err("click-at coordinates are outside the viewport or hit no element".into());
39 }
40 self.cdp
41 .dispatch_mouse_event("mouseMoved", x, y, None, None)
42 .await?;
43 self.cdp
44 .dispatch_mouse_event("mousePressed", x, y, Some("left"), Some(1))
45 .await?;
46 self.cdp
47 .dispatch_mouse_event("mouseReleased", x, y, Some("left"), Some(1))
48 .await?;
49 let (target_id, frame_id) = self.ensured_route_identity().await?;
50 Ok(CoordinateClickOutcome {
51 x,
52 y,
53 hit,
54 revision: self.invalidate_observation(),
55 target_id,
56 frame_id,
57 })
58 })
59 .await
60 }
61
62 pub async fn scroll(&self, dx: f64, dy: f64) -> BrowserResult<ActionOutcome> {
66 self.cdp
67 .with_current_route(async {
68 self.cdp.scroll_by(dx, dy).await?;
69 let (target_id, frame_id) = self.ensured_route_identity().await?;
70 Ok(ActionOutcome {
71 action: ActionKind::Scroll,
72 target: None,
73 revision: self.invalidate_observation(),
74 target_id,
75 frame_id,
76 evidence: None,
77 })
78 })
79 .await
80 }
81
82 pub async fn snapshot(&self) -> BrowserResult<AccessibilitySnapshot> {
88 self.cdp
89 .with_current_route(async {
90 let revision = self.page_revision.load(Ordering::Relaxed);
91 let raw = self.cdp.get_accessibility_tree().await?;
92 let roots = parse_accessibility_tree(&raw);
93 let interactive = interactive_elements(&roots, revision);
94 Ok(AccessibilitySnapshot {
95 page: self.page_info().await?,
96 roots,
97 interactive,
98 })
99 })
100 .await
101 }
102
103 pub async fn click(&self, target: &str) -> BrowserResult<ActionOutcome> {
105 self.pointer_click(target, false).await
106 }
107
108 pub async fn double_click(&self, target: &str) -> BrowserResult<ActionOutcome> {
111 self.pointer_click(target, true).await
112 }
113
114 pub async fn hover(&self, target: &str) -> BrowserResult<ActionOutcome> {
119 self.cdp
120 .with_current_route(async {
121 let element = self.resolve_element(target).await?;
122 let object_id = self
123 .cdp
124 .resolve_node_object(element.node_id, element.backend_dom_node_id)
125 .await?;
126 let remote = RemoteObjectGuard::new(self.cdp.clone(), object_id);
127 let local = self.verified_action_point(&remote.object_id).await?;
128 let point = self.target_viewport_point(local).await?;
129 self.move_pointer(point).await?;
130 self.action_outcome(ActionKind::Hover, Some(element), None)
131 .await
132 })
133 .await
134 }
135
136 pub async fn drag(&self, source: &str, destination: &str) -> BrowserResult<ActionOutcome> {
141 self.cdp
142 .with_current_route(async {
143 let source = self.resolve_element(source).await?;
144 let source_object = self
145 .cdp
146 .resolve_node_object(source.node_id, source.backend_dom_node_id)
147 .await?;
148 let source_guard = RemoteObjectGuard::new(self.cdp.clone(), source_object);
149 let destination = self.resolve_element(destination).await?;
150 let destination_object = self
151 .cdp
152 .resolve_node_object(destination.node_id, destination.backend_dom_node_id)
153 .await?;
154 let destination_guard =
155 RemoteObjectGuard::new(self.cdp.clone(), destination_object);
156 let source_local = self.verified_action_point(&source_guard.object_id).await?;
157 let destination_local = self
158 .verified_action_point(&destination_guard.object_id)
159 .await?;
160 let source_point = self.target_viewport_point(source_local).await?;
161 let destination_point = self.target_viewport_point(destination_local).await?;
162 self.move_pointer(source_point).await?;
163 let verified_source = self.verified_action_point(&source_guard.object_id).await?;
164 if (verified_source.x - source_local.x).abs() > 1.0
165 || (verified_source.y - source_local.y).abs() > 1.0
166 {
167 return Err(TargetError {
168 kind: TargetErrorKind::NotActionable,
169 reason: Some(TargetActionabilityReason::GeometryChanged),
170 candidates: Vec::new(),
171 recovery: None,
172 }
173 .into());
174 }
175 self.cdp
176 .dispatch_mouse_event(
177 "mousePressed",
178 source_point.x,
179 source_point.y,
180 Some("left"),
181 Some(1),
182 )
183 .await?;
184 let mut pressed = PressedButtonGuard {
185 cdp: self.cdp.clone(),
186 point: source_point,
187 click_count: 1,
188 armed: true,
189 };
190 let drag_path = interaction_path(
191 self.interaction_mode,
192 &self.mouse,
193 source_point,
194 destination_point,
195 );
196 for window in drag_path.windows(2) {
197 let point = window[1];
198 if self.interaction_mode == InteractionMode::Human {
199 tokio::time::sleep(self.mouse.move_delay(window[0], point)).await;
200 }
201 self.cdp
202 .dispatch_mouse_event("mouseMoved", point.x, point.y, Some("left"), Some(1))
203 .await?;
204 }
205 let verified_destination = self
206 .verified_action_point(&destination_guard.object_id)
207 .await?;
208 if (verified_destination.x - destination_local.x).abs() > 1.0
209 || (verified_destination.y - destination_local.y).abs() > 1.0
210 {
211 return Err(TargetError {
212 kind: TargetErrorKind::NotActionable,
213 reason: Some(TargetActionabilityReason::GeometryChanged),
214 candidates: Vec::new(),
215 recovery: None,
216 }
217 .into());
218 }
219 self.cdp
220 .dispatch_mouse_event(
221 "mouseReleased",
222 destination_point.x,
223 destination_point.y,
224 Some("left"),
225 Some(1),
226 )
227 .await?;
228 pressed.armed = false;
229 *self.pointer.lock().await = Some(destination_point);
230 self.action_outcome(ActionKind::Drag, Some(source), None)
231 .await
232 })
233 .await
234 }
235
236 pub async fn key_down(&self, key: &str) -> BrowserResult<ActionOutcome> {
240 self.keyboard_action(ActionKind::KeyDown, key, "rawKeyDown", 0)
241 .await
242 }
243
244 pub async fn key_up(&self, key: &str) -> BrowserResult<ActionOutcome> {
248 self.keyboard_action(ActionKind::KeyUp, key, "keyUp", 0)
249 .await
250 }
251
252 pub async fn key_press(&self, key: &str) -> BrowserResult<ActionOutcome> {
257 validate_key(key)?;
258 self.cdp
259 .with_current_route(async {
260 let code = key_code(key);
261 self.cdp
262 .dispatch_key_event_with_modifiers("rawKeyDown", key, &code, "", 0)
263 .await?;
264 if key.chars().count() == 1 {
265 self.cdp
266 .dispatch_key_event_with_modifiers("char", key, &code, key, 0)
267 .await?;
268 }
269 self.cdp
270 .dispatch_key_event_with_modifiers("keyUp", key, &code, "", 0)
271 .await?;
272 self.action_outcome(ActionKind::KeyPress, None, None).await
273 })
274 .await
275 }
276
277 pub async fn shortcut(&self, shortcut: &str) -> BrowserResult<ActionOutcome> {
282 let (modifiers, key) = parse_shortcut(shortcut)?;
283 self.cdp
284 .with_current_route(async {
285 let code = key_code(&key);
286 self.cdp
287 .dispatch_key_event_with_modifiers("rawKeyDown", &key, &code, "", modifiers)
288 .await?;
289 self.cdp
290 .dispatch_key_event_with_modifiers("keyUp", &key, &code, "", modifiers)
291 .await?;
292 self.action_outcome(ActionKind::Shortcut, None, None).await
293 })
294 .await
295 }
296
297 pub async fn clear(&self, target: &str) -> BrowserResult<ActionOutcome> {
302 self.cdp
303 .with_current_route(async {
304 let element = self.resolve_element(target).await?;
305 let object_id = self.cdp.resolve_node_object(element.node_id, element.backend_dom_node_id).await?;
306 let remote = RemoteObjectGuard::new(self.cdp.clone(), object_id);
307 let editable = runtime_value(&self.cdp.call_on_object(&remote.object_id, "function(){return this instanceof HTMLInputElement || this instanceof HTMLTextAreaElement || this.isContentEditable}").await?)?;
308 if editable.as_bool() != Some(true) { return Err("clear target is not editable".into()); }
309 let clicked = self.click(target).await?;
310 self.cdp.dispatch_select_all().await?;
311 self.key_press("Backspace").await?;
312 let empty = runtime_value(&self.cdp.call_on_object(&remote.object_id, "function(){return this instanceof HTMLInputElement || this instanceof HTMLTextAreaElement ? this.value === '' : this.textContent === ''}").await?)?;
313 if empty.as_bool() != Some(true) { return Err("clear target did not become empty".into()); }
314 self.action_outcome_from_target(ActionKind::Clear, clicked.target)
315 .await
316 })
317 .await
318 }
319
320 pub async fn check(&self, target: &str) -> BrowserResult<ActionOutcome> {
324 self.set_checked(target, true).await
325 }
326
327 pub async fn uncheck(&self, target: &str) -> BrowserResult<ActionOutcome> {
331 self.set_checked(target, false).await
332 }
333
334 pub async fn select_option(&self, target: &str, value: &str) -> BrowserResult<ActionOutcome> {
338 if value.is_empty() || value.len() > 4096 {
339 return Err("select value must be 1..=4096 bytes".into());
340 }
341 let value_json = serde_json::to_string(value)?;
342 self.form_object_action(target, ActionKind::Select, &format!(r#"function() {{ if (!(this instanceof HTMLSelectElement)) return {{ok:false,reason:'not_select'}}; const option = Array.from(this.options).find(option => option.value === {value_json}); if (!option) return {{ok:false,reason:'option_not_found'}}; this.value = option.value; this.dispatchEvent(new Event('input',{{bubbles:true}})); this.dispatchEvent(new Event('change',{{bubbles:true}})); return {{ok:this.value === option.value}}; }}"#)).await
343 }
344
345 pub async fn upload_files(
346 &self,
347 target: &str,
348 paths: &[PathBuf],
349 ) -> BrowserResult<ActionOutcome> {
350 self.policy.require(PolicyCapability::Upload)?;
351 self.cdp.with_current_route(async {
352 if paths.is_empty() || paths.len() > 16 { return Err("upload requires 1..=16 files".into()); }
353 let mut files = Vec::with_capacity(paths.len());
354 for path in paths {
355 let canonical = self.policy.require_existing_path(path)?;
356 if !canonical.is_file() { return Err("upload path must be a regular file".into()); }
357 if !canonical.starts_with(&self.upload_root) { return Err("upload path is outside the allowed workspace root".into()); }
358 files.push(canonical.to_string_lossy().into_owned());
359 }
360 let element = self.resolve_element(target).await?;
361 let object_id = self.cdp.resolve_node_object(element.node_id, element.backend_dom_node_id).await?;
362 let remote = RemoteObjectGuard::new(self.cdp.clone(), object_id);
363 self.verified_action_point(&remote.object_id).await?;
364 let input = runtime_value(&self.cdp.call_on_object(&remote.object_id, "function(){return {ok:this instanceof HTMLInputElement && this.type === 'file'}}").await?)?;
365 if input["ok"].as_bool() != Some(true) { return Err("upload target is not a file input".into()); }
366 if element.node_id.is_none() && element.backend_dom_node_id.is_none() { return Err("file input target has no DOM node ID".into()); }
367 self.cdp.set_file_input_files(element.node_id, element.backend_dom_node_id, &files).await?;
368 let verified = runtime_value(&self.cdp.call_on_object(&remote.object_id, "function(){return this.files.length}").await?)?;
369 if verified.as_u64() != Some(files.len() as u64) { return Err("file input did not retain the requested file count".into()); }
370 let outcome = self.action_outcome(ActionKind::Upload, Some(element), Some(serde_json::json!({"file_count": files.len()}))).await?;
371 self.record_audit("upload", format!("{} files", files.len()));
372 Ok(outcome)
373 }).await
374 }
375
376 async fn resolve_click_target(
377 &self,
378 target: &str,
379 ) -> BrowserResult<(ResolvedElement, String, Point)> {
380 const MAX_NODE_RESOLUTION_ATTEMPTS: usize = 3;
381 let mut last_error: Option<Box<dyn Error>> = None;
382
383 for attempt in 0..MAX_NODE_RESOLUTION_ATTEMPTS {
384 let element = self.resolve_element(target).await?;
385 let object_id = match self
386 .cdp
387 .resolve_node_object(element.node_id, element.backend_dom_node_id)
388 .await
389 {
390 Ok(object_id) => object_id,
391 Err(error) => {
392 tracing::debug!(%error, attempt, "target node could not be resolved");
393 last_error = Some(Box::new(TargetError {
394 kind: TargetErrorKind::NotActionable,
395 reason: Some(TargetActionabilityReason::NodeUnavailable),
396 candidates: Vec::new(),
397 recovery: None,
398 }));
399 if attempt + 1 < MAX_NODE_RESOLUTION_ATTEMPTS {
400 tokio::time::sleep(Duration::from_millis(50)).await;
401 continue;
402 }
403 break;
404 }
405 };
406
407 let local_point = match self.verified_action_point(&object_id).await {
408 Ok(point) => point,
409 Err(error)
410 if error
411 .downcast_ref::<TargetError>()
412 .and_then(|target_error| target_error.reason)
413 == Some(TargetActionabilityReason::NodeUnavailable) =>
414 {
415 last_error = Some(error);
416 if attempt + 1 < MAX_NODE_RESOLUTION_ATTEMPTS {
417 tokio::time::sleep(Duration::from_millis(50)).await;
418 continue;
419 }
420 break;
421 }
422 Err(error) => return Err(error),
423 };
424
425 return Ok((element, object_id, local_point));
426 }
427
428 Err(last_error.expect("node resolution retry loop must retain its last error"))
429 }
430
431 async fn pointer_click(
432 &self,
433 target: &str,
434 double_click: bool,
435 ) -> BrowserResult<ActionOutcome> {
436 self.cdp
437 .with_current_route(async {
438 let (element, object_id, local_point) = self.resolve_click_target(target).await?;
439 let remote = RemoteObjectGuard::new(self.cdp.clone(), object_id);
440 let point = self.target_viewport_point(local_point).await?;
441 let events = if double_click {
442 self.mouse.generate_double_click_events(point)
443 } else {
444 self.mouse.generate_click_events(point)
445 };
446 self.dispatch_pointer_events(&remote.object_id, local_point, point, events)
447 .await?;
448 let (target_id, frame_id) = self.route_identity().await?;
449 Ok(ActionOutcome {
450 action: if double_click {
451 ActionKind::DoubleClick
452 } else {
453 ActionKind::Click
454 },
455 target: Some(ActionTarget {
456 label: element.label,
457 reference: element.reference,
458 }),
459 revision: self.invalidate_observation(),
460 target_id,
461 frame_id,
462 evidence: None,
463 })
464 })
465 .await
466 }
467
468 async fn dispatch_pointer_events(
469 &self,
470 object_id: &str,
471 local_point: Point,
472 point: Point,
473 events: Vec<crate::browser::mouse::MouseEvent>,
474 ) -> BrowserResult<()> {
475 let mut pointer = self.pointer.lock().await;
476 let start = match (self.interaction_mode, *pointer) {
477 (_, Some(point)) => point,
478 (InteractionMode::Human, None) => self
479 .viewport_center()
480 .await
481 .unwrap_or(Point { x: 640.0, y: 360.0 }),
482 (InteractionMode::Fast, None) => point,
483 };
484 let path = interaction_path(self.interaction_mode, &self.mouse, start, point);
485 if self.interaction_mode == InteractionMode::Human && pointer.is_none() {
486 self.cdp
487 .dispatch_mouse_event("mouseMoved", start.x, start.y, None, None)
488 .await?;
489 }
490 for window in path.windows(2) {
491 let next = window[1];
492 if self.interaction_mode == InteractionMode::Human {
493 tokio::time::sleep(self.mouse.move_delay(window[0], next)).await;
494 }
495 self.cdp
496 .dispatch_mouse_event("mouseMoved", next.x, next.y, None, None)
497 .await?;
498 }
499 let press_point = self.verified_action_point(object_id).await?;
500 if (press_point.x - local_point.x).abs() > 1.0
501 || (press_point.y - local_point.y).abs() > 1.0
502 {
503 return Err(TargetError {
504 kind: TargetErrorKind::NotActionable,
505 reason: Some(TargetActionabilityReason::GeometryChanged),
506 candidates: Vec::new(),
507 recovery: None,
508 }
509 .into());
510 }
511 let mut pressed = None;
512 for event in events {
513 if event.event_type == "mousePressed" {
514 pressed = Some(PressedButtonGuard {
515 cdp: self.cdp.clone(),
516 point,
517 click_count: event.click_count,
518 armed: true,
519 });
520 }
521 self.cdp
522 .dispatch_mouse_event(
523 &event.event_type,
524 event.x,
525 event.y,
526 Some(&event.button),
527 Some(event.click_count),
528 )
529 .await?;
530 if event.event_type == "mouseReleased"
531 && let Some(mut guard) = pressed.take()
532 {
533 guard.armed = false;
534 }
535 if self.interaction_mode == InteractionMode::Human && event.event_type == "mousePressed"
536 {
537 tokio::time::sleep(self.mouse.click_delay()).await;
538 }
539 }
540 *pointer = Some(point);
541 Ok(())
542 }
543
544 pub async fn type_text(
550 &self,
551 text: &str,
552 target: Option<&str>,
553 ) -> BrowserResult<ActionOutcome> {
554 self.cdp
555 .with_current_route(async {
556 let target = match target {
557 Some(target) => self.click(target).await?.target,
558 None => None,
559 };
560 self.cdp.insert_text(text).await?;
561 let (target_id, frame_id) = self.route_identity().await?;
562 Ok(ActionOutcome {
563 action: ActionKind::Type,
564 target,
565 revision: self.invalidate_observation(),
566 target_id,
567 frame_id,
568 evidence: None,
569 })
570 })
571 .await
572 }
573
574 async fn move_pointer(&self, destination: Point) -> BrowserResult<()> {
575 let mut pointer = self.pointer.lock().await;
576 let start = pointer.unwrap_or(destination);
577 for window in
578 interaction_path(self.interaction_mode, &self.mouse, start, destination).windows(2)
579 {
580 if self.interaction_mode == InteractionMode::Human {
581 tokio::time::sleep(self.mouse.move_delay(window[0], window[1])).await;
582 }
583 self.cdp
584 .dispatch_mouse_event("mouseMoved", window[1].x, window[1].y, None, None)
585 .await?;
586 }
587 if start == destination {
588 self.cdp
589 .dispatch_mouse_event("mouseMoved", destination.x, destination.y, None, None)
590 .await?;
591 }
592 *pointer = Some(destination);
593 Ok(())
594 }
595
596 async fn keyboard_action(
597 &self,
598 action: ActionKind,
599 key: &str,
600 event_type: &str,
601 modifiers: i64,
602 ) -> BrowserResult<ActionOutcome> {
603 validate_key(key)?;
604 self.cdp
605 .with_current_route(async {
606 self.cdp
607 .dispatch_key_event_with_modifiers(
608 event_type,
609 key,
610 &key_code(key),
611 "",
612 modifiers,
613 )
614 .await?;
615 self.action_outcome(action, None, None).await
616 })
617 .await
618 }
619
620 async fn set_checked(&self, target: &str, checked: bool) -> BrowserResult<ActionOutcome> {
621 let action = if checked {
622 ActionKind::Check
623 } else {
624 ActionKind::Uncheck
625 };
626 let script = format!(
627 r#"function() {{ if (!(this instanceof HTMLInputElement) || !['checkbox','radio'].includes(this.type)) return {{ok:false,reason:'not_checkable'}}; if (this.checked !== {checked}) this.click(); return {{ok:this.checked === {checked}}}; }}"#
628 );
629 self.form_object_action(target, action, &script).await
630 }
631
632 async fn form_object_action(
633 &self,
634 target: &str,
635 action: ActionKind,
636 function: &str,
637 ) -> BrowserResult<ActionOutcome> {
638 self.cdp
639 .with_current_route(async {
640 let element = self.resolve_element(target).await?;
641 let object_id = self
642 .cdp
643 .resolve_node_object(element.node_id, element.backend_dom_node_id)
644 .await?;
645 let remote = RemoteObjectGuard::new(self.cdp.clone(), object_id);
646 self.verified_action_point(&remote.object_id).await?;
647 let result = self.cdp.call_on_object(&remote.object_id, function).await?;
648 let value = runtime_value(&result)?;
649 if value["ok"].as_bool() != Some(true) {
650 return Err(format!(
651 "form action failed: {}",
652 value["reason"].as_str().unwrap_or("verification_failed")
653 )
654 .into());
655 }
656 self.action_outcome(action, Some(element), None).await
657 })
658 .await
659 }
660
661 async fn action_outcome(
662 &self,
663 action: ActionKind,
664 element: Option<ResolvedElement>,
665 evidence: Option<Value>,
666 ) -> BrowserResult<ActionOutcome> {
667 let target = element.map(|element| ActionTarget {
668 label: element.label,
669 reference: element.reference,
670 });
671 let mut outcome = self.action_outcome_from_target(action, target).await?;
672 outcome.evidence = evidence;
673 Ok(outcome)
674 }
675
676 pub(crate) async fn action_outcome_from_target(
677 &self,
678 action: ActionKind,
679 target: Option<ActionTarget>,
680 ) -> BrowserResult<ActionOutcome> {
681 if let Some(interception) = &self.policy_interception {
682 let _ = self.cdp.evaluate("0").await;
686 tokio::task::yield_now().await;
687 if let Some(error) = interception.take_denial().await {
688 return Err(error.into());
689 }
690 }
691 let (target_id, frame_id) = self.route_identity().await?;
692 Ok(ActionOutcome {
693 action,
694 target,
695 revision: self.invalidate_observation(),
696 target_id,
697 frame_id,
698 evidence: None,
699 })
700 }
701
702 pub(crate) async fn viewport_center(&self) -> BrowserResult<Point> {
703 let value = self
704 .evaluate_value("[window.innerWidth / 2, window.innerHeight / 2]")
705 .await?;
706 let coordinates = value
707 .as_array()
708 .filter(|coordinates| coordinates.len() == 2)
709 .ok_or("viewport evaluation returned invalid coordinates")?;
710 let x = coordinates[0]
711 .as_f64()
712 .ok_or("viewport width was not numeric")?;
713 let y = coordinates[1]
714 .as_f64()
715 .ok_or("viewport height was not numeric")?;
716 Ok(Point { x, y })
717 }
718
719 pub(crate) async fn target_viewport_point(&self, point: Point) -> BrowserResult<Point> {
720 let Some(frame_id) = self.cdp.active_frame() else {
721 return Ok(point);
722 };
723 let frame = {
724 let topology = self.topology.lock().await;
725 topology
726 .frames
727 .iter()
728 .find(|frame| frame.id == frame_id)
729 .cloned()
730 };
731 let frame = match frame {
732 Some(frame) => frame,
733 None => self
734 .list_frames()
735 .await?
736 .into_iter()
737 .find(|frame| frame.id == frame_id)
738 .ok_or("selected frame is no longer attached")?,
739 };
740 if frame.parent_id.is_none() {
741 return Ok(point);
742 }
743 let (x, y) = self.cdp.frame_viewport_offset(&frame_id).await?;
744 Ok(Point {
745 x: point.x + x,
746 y: point.y + y,
747 })
748 }
749
750 pub(crate) async fn evaluate_value(&self, expression: &str) -> BrowserResult<Value> {
751 let raw = self.cdp.evaluate(expression).await?;
752 runtime_value(&raw)
753 }
754
755 pub(crate) fn invalidate_observation(&self) -> u64 {
756 self.page_revision.fetch_add(1, Ordering::Relaxed) + 1
757 }
758
759 pub(crate) async fn verified_action_point(&self, object_id: &str) -> BrowserResult<Point> {
760 let raw = match self.cdp.call_on_object(object_id, HIT_TEST_FUNCTION).await {
761 Ok(raw) => raw,
762 Err(error) => {
763 tracing::debug!(%error, "target node could not be verified");
764 return Err(TargetError {
765 kind: TargetErrorKind::NotActionable,
766 reason: Some(TargetActionabilityReason::NodeUnavailable),
767 candidates: Vec::new(),
768 recovery: None,
769 }
770 .into());
771 }
772 };
773 let value = runtime_value(&raw)?;
774 if value["ok"].as_bool() != Some(true) {
775 let reason = value["reason"].as_str().unwrap_or("verification_failed");
776 tracing::debug!(reason, "target actionability check failed");
777 return Err(TargetError {
778 kind: TargetErrorKind::NotActionable,
779 reason: Some(actionability_reason(reason)),
780 candidates: Vec::new(),
781 recovery: None,
782 }
783 .into());
784 }
785 let x = value["x"]
786 .as_f64()
787 .ok_or("verified target x was not numeric")?;
788 let y = value["y"]
789 .as_f64()
790 .ok_or("verified target y was not numeric")?;
791 Ok(Point { x, y })
792 }
793}