1#![allow(missing_docs)]
2use std::collections::HashMap;
3
4use serde_json::Value;
5
6use super::cdp::client::CdpClient;
7use super::cdp::types::*;
8use super::element::{resolve_element_center, resolve_element_object_id, RefMap};
9
10#[derive(Default)]
17pub struct ClickResult {
18 pub dialog_opened: bool,
19 pub pending_release: Option<PendingRelease>,
20}
21
22pub struct PendingRelease {
23 pub session_id: String,
24 pub x: f64,
25 pub y: f64,
26 pub button: String,
27}
28
29pub async fn click(
30 client: &CdpClient,
31 session_id: &str,
32 ref_map: &RefMap,
33 selector_or_ref: &str,
34 button: &str,
35 click_count: i32,
36 iframe_sessions: &HashMap<String, String>,
37) -> Result<ClickResult, String> {
38 let (x, y, effective_session_id) = resolve_element_center(
39 client,
40 session_id,
41 ref_map,
42 selector_or_ref,
43 iframe_sessions,
44 )
45 .await?;
46 dispatch_click(
50 client,
51 &effective_session_id,
52 &[effective_session_id.as_str(), session_id],
53 x,
54 y,
55 button,
56 click_count,
57 )
58 .await
59}
60
61pub async fn dblclick(
62 client: &CdpClient,
63 session_id: &str,
64 ref_map: &RefMap,
65 selector_or_ref: &str,
66 iframe_sessions: &HashMap<String, String>,
67) -> Result<ClickResult, String> {
68 click(
69 client,
70 session_id,
71 ref_map,
72 selector_or_ref,
73 "left",
74 2,
75 iframe_sessions,
76 )
77 .await
78}
79
80pub async fn hover(
81 client: &CdpClient,
82 session_id: &str,
83 ref_map: &RefMap,
84 selector_or_ref: &str,
85 iframe_sessions: &HashMap<String, String>,
86) -> Result<(), String> {
87 let (x, y, effective_session_id) = resolve_element_center(
88 client,
89 session_id,
90 ref_map,
91 selector_or_ref,
92 iframe_sessions,
93 )
94 .await?;
95 client
96 .send_command_typed::<_, Value>(
97 "Input.dispatchMouseEvent",
98 &DispatchMouseEventParams {
99 event_type: "mouseMoved".to_string(),
100 x,
101 y,
102 button: None,
103 buttons: None,
104 click_count: None,
105 delta_x: None,
106 delta_y: None,
107 modifiers: None,
108 },
109 Some(&effective_session_id),
110 )
111 .await?;
112 Ok(())
113}
114
115pub async fn drag(
117 client: &CdpClient,
118 session_id: &str,
119 ref_map: &RefMap,
120 from: &str,
121 to: &str,
122 iframe_sessions: &HashMap<String, String>,
123) -> Result<(), String> {
124 let (x1, y1, sid1) =
125 resolve_element_center(client, session_id, ref_map, from, iframe_sessions).await?;
126 let (x2, y2, sid2) =
127 resolve_element_center(client, session_id, ref_map, to, iframe_sessions).await?;
128 if sid1 != sid2 {
129 return Err("drag endpoints must share the same frame/session".to_string());
130 }
131 let sid = sid1;
132 for (event_type, x, y, button, buttons, click_count) in [
133 ("mouseMoved", x1, y1, None, None, None),
134 (
135 "mousePressed",
136 x1,
137 y1,
138 Some("left".to_string()),
139 Some(1),
140 Some(1),
141 ),
142 (
143 "mouseMoved",
144 x2,
145 y2,
146 Some("left".to_string()),
147 Some(1),
148 None,
149 ),
150 (
151 "mouseReleased",
152 x2,
153 y2,
154 Some("left".to_string()),
155 Some(0),
156 Some(1),
157 ),
158 ] {
159 client
160 .send_command_typed::<_, Value>(
161 "Input.dispatchMouseEvent",
162 &DispatchMouseEventParams {
163 event_type: event_type.to_string(),
164 x,
165 y,
166 button,
167 buttons,
168 click_count,
169 delta_x: None,
170 delta_y: None,
171 modifiers: None,
172 },
173 Some(&sid),
174 )
175 .await?;
176 }
177 Ok(())
178}
179
180pub async fn fill(
181 client: &CdpClient,
182 session_id: &str,
183 ref_map: &RefMap,
184 selector_or_ref: &str,
185 value: &str,
186 iframe_sessions: &HashMap<String, String>,
187) -> Result<(), String> {
188 let (object_id, effective_session_id) = resolve_element_object_id(
189 client,
190 session_id,
191 ref_map,
192 selector_or_ref,
193 iframe_sessions,
194 )
195 .await?;
196
197 client
199 .send_command_typed::<_, Value>(
200 "Runtime.callFunctionOn",
201 &CallFunctionOnParams {
202 function_declaration: "function() { this.focus(); }".to_string(),
203 object_id: Some(object_id.clone()),
204 arguments: None,
205 return_by_value: Some(true),
206 await_promise: Some(false),
207 },
208 Some(&effective_session_id),
209 )
210 .await?;
211
212 client
214 .send_command_typed::<_, Value>(
215 "Runtime.callFunctionOn",
216 &CallFunctionOnParams {
217 function_declaration: r#"function() {
218 this.select && this.select();
219 this.value = '';
220 this.dispatchEvent(new Event('input', { bubbles: true }));
221 }"#
222 .to_string(),
223 object_id: Some(object_id),
224 arguments: None,
225 return_by_value: Some(true),
226 await_promise: Some(false),
227 },
228 Some(&effective_session_id),
229 )
230 .await?;
231
232 client
234 .send_command_typed::<_, Value>(
235 "Input.insertText",
236 &InsertTextParams {
237 text: value.to_string(),
238 },
239 Some(session_id),
240 )
241 .await?;
242
243 Ok(())
244}
245
246pub async fn fill_smart(
251 client: &CdpClient,
252 session_id: &str,
253 ref_map: &RefMap,
254 selector_or_ref: &str,
255 value: &str,
256 iframe_sessions: &HashMap<String, String>,
257) -> Result<(), String> {
258 let kind = detect_fill_kind(
259 client,
260 session_id,
261 ref_map,
262 selector_or_ref,
263 iframe_sessions,
264 )
265 .await?;
266 match kind.as_str() {
267 "select" => {
268 select_option(
269 client,
270 session_id,
271 ref_map,
272 selector_or_ref,
273 &[value.to_string()],
274 iframe_sessions,
275 )
276 .await
277 }
278 "checkbox" => {
279 let want = parse_boolish(value);
280 if want {
281 check(
282 client,
283 session_id,
284 ref_map,
285 selector_or_ref,
286 iframe_sessions,
287 )
288 .await
289 } else {
290 uncheck(
291 client,
292 session_id,
293 ref_map,
294 selector_or_ref,
295 iframe_sessions,
296 )
297 .await
298 }
299 }
300 "radio" => {
301 let want = parse_boolish(value);
302 if want {
303 check(
305 client,
306 session_id,
307 ref_map,
308 selector_or_ref,
309 iframe_sessions,
310 )
311 .await
312 } else {
313 Err("radio cannot be set to false via fill; select another radio option".into())
314 }
315 }
316 _ => {
317 fill(
318 client,
319 session_id,
320 ref_map,
321 selector_or_ref,
322 value,
323 iframe_sessions,
324 )
325 .await
326 }
327 }
328}
329
330fn parse_boolish(value: &str) -> bool {
331 matches!(
332 value.trim().to_ascii_lowercase().as_str(),
333 "true" | "1" | "on" | "yes" | "checked"
334 )
335}
336
337async fn detect_fill_kind(
338 client: &CdpClient,
339 session_id: &str,
340 ref_map: &RefMap,
341 selector_or_ref: &str,
342 iframe_sessions: &HashMap<String, String>,
343) -> Result<String, String> {
344 let (object_id, effective_session_id) = resolve_element_object_id(
345 client,
346 session_id,
347 ref_map,
348 selector_or_ref,
349 iframe_sessions,
350 )
351 .await?;
352 let result = client
353 .send_command_typed::<_, Value>(
354 "Runtime.callFunctionOn",
355 &CallFunctionOnParams {
356 function_declaration: r#"function() {
357 const tag = (this.tagName || '').toUpperCase();
358 if (tag === 'SELECT') return 'select';
359 if (tag === 'INPUT') {
360 const t = (this.type || 'text').toLowerCase();
361 if (t === 'checkbox') return 'checkbox';
362 if (t === 'radio') return 'radio';
363 }
364 if (this.getAttribute && this.getAttribute('role') === 'checkbox') return 'checkbox';
365 if (this.getAttribute && this.getAttribute('role') === 'radio') return 'radio';
366 return 'text';
367 }"#
368 .to_string(),
369 object_id: Some(object_id),
370 arguments: None,
371 return_by_value: Some(true),
372 await_promise: Some(false),
373 },
374 Some(&effective_session_id),
375 )
376 .await?;
377 Ok(result
378 .get("result")
379 .and_then(|r| r.get("value"))
380 .and_then(|v| v.as_str())
381 .unwrap_or("text")
382 .to_string())
383}
384
385pub async fn click_at(
387 client: &CdpClient,
388 session_id: &str,
389 x: f64,
390 y: f64,
391 dblclick: bool,
392) -> Result<ClickResult, String> {
393 let click_count = if dblclick { 2 } else { 1 };
394 dispatch_click(client, session_id, &[session_id], x, y, "left", click_count).await
395}
396
397#[allow(clippy::too_many_arguments)]
398pub async fn type_text(
399 client: &CdpClient,
400 session_id: &str,
401 ref_map: &RefMap,
402 selector_or_ref: &str,
403 text: &str,
404 clear: bool,
405 delay_ms: Option<u64>,
406 iframe_sessions: &HashMap<String, String>,
407) -> Result<(), String> {
408 let (object_id, effective_session_id) = resolve_element_object_id(
409 client,
410 session_id,
411 ref_map,
412 selector_or_ref,
413 iframe_sessions,
414 )
415 .await?;
416
417 client
419 .send_command_typed::<_, Value>(
420 "Runtime.callFunctionOn",
421 &CallFunctionOnParams {
422 function_declaration: "function() { this.focus(); }".to_string(),
423 object_id: Some(object_id.clone()),
424 arguments: None,
425 return_by_value: Some(true),
426 await_promise: Some(false),
427 },
428 Some(&effective_session_id),
429 )
430 .await?;
431
432 if clear {
433 client
434 .send_command_typed::<_, Value>(
435 "Runtime.callFunctionOn",
436 &CallFunctionOnParams {
437 function_declaration: r#"function() {
438 this.select && this.select();
439 this.value = '';
440 this.dispatchEvent(new Event('input', { bubbles: true }));
441 }"#
442 .to_string(),
443 object_id: Some(object_id),
444 arguments: None,
445 return_by_value: Some(true),
446 await_promise: Some(false),
447 },
448 Some(&effective_session_id),
449 )
450 .await?;
451 }
452
453 type_text_into_active_context(client, session_id, text, delay_ms).await
454}
455
456pub async fn type_text_into_active_context(
457 client: &CdpClient,
458 session_id: &str,
459 text: &str,
460 delay_ms: Option<u64>,
461) -> Result<(), String> {
462 let delay = delay_ms.unwrap_or(0);
463
464 for ch in text.chars() {
465 if matches!(ch, '\n' | '\r' | '\t') {
466 let (key, code, key_code) = char_to_key_info(ch);
467 let text_str = key_text(&key);
468 client
469 .send_command_typed::<_, Value>(
470 "Input.dispatchKeyEvent",
471 &DispatchKeyEventParams {
472 event_type: "keyDown".to_string(),
473 key: Some(key.clone()),
474 code: Some(code.clone()),
475 text: text_str.clone(),
476 unmodified_text: text_str,
477 windows_virtual_key_code: Some(key_code),
478 native_virtual_key_code: Some(key_code),
479 modifiers: None,
480 },
481 Some(session_id),
482 )
483 .await?;
484
485 client
486 .send_command_typed::<_, Value>(
487 "Input.dispatchKeyEvent",
488 &DispatchKeyEventParams {
489 event_type: "keyUp".to_string(),
490 key: Some(key),
491 code: Some(code),
492 text: None,
493 unmodified_text: None,
494 windows_virtual_key_code: Some(key_code),
495 native_virtual_key_code: Some(key_code),
496 modifiers: None,
497 },
498 Some(session_id),
499 )
500 .await?;
501 } else {
502 client
506 .send_command_typed::<_, Value>(
507 "Input.insertText",
508 &InsertTextParams {
509 text: ch.to_string(),
510 },
511 Some(session_id),
512 )
513 .await?;
514 }
515
516 if delay > 0 {
517 tokio::time::sleep(tokio::time::Duration::from_millis(delay)).await;
518 }
519 }
520
521 Ok(())
522}
523
524pub async fn press_key(client: &CdpClient, session_id: &str, key: &str) -> Result<(), String> {
525 press_key_with_modifiers(client, session_id, key, None).await
526}
527
528pub async fn press_key_with_modifiers(
536 client: &CdpClient,
537 session_id: &str,
538 key: &str,
539 modifiers: Option<i32>,
540) -> Result<(), String> {
541 let (key_name, code, key_code) = named_key_info(key);
542
543 let has_command_modifier = modifiers.is_some_and(|m| m & (2 | 4) != 0);
546 let text = if has_command_modifier {
547 None
548 } else {
549 key_text(&key_name)
550 };
551
552 client
553 .send_command_typed::<_, Value>(
554 "Input.dispatchKeyEvent",
555 &DispatchKeyEventParams {
556 event_type: "keyDown".to_string(),
557 key: Some(key_name.clone()),
558 code: Some(code.clone()),
559 text: text.clone(),
560 unmodified_text: text.clone(),
561 windows_virtual_key_code: Some(key_code),
562 native_virtual_key_code: Some(key_code),
563 modifiers,
564 },
565 Some(session_id),
566 )
567 .await?;
568
569 client
570 .send_command_typed::<_, Value>(
571 "Input.dispatchKeyEvent",
572 &DispatchKeyEventParams {
573 event_type: "keyUp".to_string(),
574 key: Some(key_name),
575 code: Some(code),
576 text: None,
577 unmodified_text: None,
578 windows_virtual_key_code: Some(key_code),
579 native_virtual_key_code: Some(key_code),
580 modifiers,
581 },
582 Some(session_id),
583 )
584 .await?;
585
586 Ok(())
587}
588
589pub async fn scroll(
590 client: &CdpClient,
591 session_id: &str,
592 ref_map: &RefMap,
593 selector_or_ref: Option<&str>,
594 delta_x: f64,
595 delta_y: f64,
596 iframe_sessions: &HashMap<String, String>,
597) -> Result<(), String> {
598 if let Some(sel) = selector_or_ref {
599 let (object_id, effective_session_id) =
600 resolve_element_object_id(client, session_id, ref_map, sel, iframe_sessions).await?;
601 let js = "function(dx, dy) { this.scrollBy(dx, dy); }".to_string();
602 client
603 .send_command_typed::<_, Value>(
604 "Runtime.callFunctionOn",
605 &CallFunctionOnParams {
606 function_declaration: js,
607 object_id: Some(object_id),
608 arguments: Some(vec![
609 CallArgument {
610 value: Some(serde_json::json!(delta_x)),
611 object_id: None,
612 },
613 CallArgument {
614 value: Some(serde_json::json!(delta_y)),
615 object_id: None,
616 },
617 ]),
618 return_by_value: Some(true),
619 await_promise: Some(false),
620 },
621 Some(&effective_session_id),
622 )
623 .await?;
624 } else {
625 let js = format!("window.scrollBy({}, {})", delta_x, delta_y);
626 client
627 .send_command_typed::<_, Value>(
628 "Runtime.evaluate",
629 &EvaluateParams {
630 expression: js,
631 return_by_value: Some(true),
632 await_promise: Some(false),
633 },
634 Some(session_id),
635 )
636 .await?;
637 }
638 Ok(())
639}
640
641pub async fn select_option(
642 client: &CdpClient,
643 session_id: &str,
644 ref_map: &RefMap,
645 selector_or_ref: &str,
646 values: &[String],
647 iframe_sessions: &HashMap<String, String>,
648) -> Result<(), String> {
649 let (object_id, effective_session_id) = resolve_element_object_id(
650 client,
651 session_id,
652 ref_map,
653 selector_or_ref,
654 iframe_sessions,
655 )
656 .await?;
657
658 let js = r#"function(vals) {
662 const options = Array.from(this.options);
663 let matched = 0;
664 for (const opt of options) {
665 opt.selected = vals.includes(opt.value) || vals.includes(opt.textContent.trim());
666 if (opt.selected) matched += 1;
667 }
668 if (matched === 0) {
669 const available = options.map(o => o.value + ' ("' + o.textContent.trim() + '")').join(', ');
670 return { error: 'No option matched ' + JSON.stringify(vals) + '. Available options: ' + available };
671 }
672 this.dispatchEvent(new Event('change', { bubbles: true }));
673 return { matched };
674 }"#
675 .to_string();
676
677 let result = client
678 .send_command_typed::<_, Value>(
679 "Runtime.callFunctionOn",
680 &CallFunctionOnParams {
681 function_declaration: js,
682 object_id: Some(object_id),
683 arguments: Some(vec![CallArgument {
684 value: Some(serde_json::json!(values)),
685 object_id: None,
686 }]),
687 return_by_value: Some(true),
688 await_promise: Some(false),
689 },
690 Some(&effective_session_id),
691 )
692 .await?;
693
694 if let Some(error) = result
695 .get("result")
696 .and_then(|r| r.get("value"))
697 .and_then(|v| v.get("error"))
698 .and_then(|e| e.as_str())
699 {
700 return Err(error.to_string());
701 }
702
703 Ok(())
704}
705
706pub async fn check(
707 client: &CdpClient,
708 session_id: &str,
709 ref_map: &RefMap,
710 selector_or_ref: &str,
711 iframe_sessions: &HashMap<String, String>,
712) -> Result<(), String> {
713 let is_checked = super::element::is_element_checked(
714 client,
715 session_id,
716 ref_map,
717 selector_or_ref,
718 iframe_sessions,
719 )
720 .await?;
721 if !is_checked {
722 click(
723 client,
724 session_id,
725 ref_map,
726 selector_or_ref,
727 "left",
728 1,
729 iframe_sessions,
730 )
731 .await?;
732
733 if !super::element::is_element_checked(
737 client,
738 session_id,
739 ref_map,
740 selector_or_ref,
741 iframe_sessions,
742 )
743 .await?
744 {
745 js_click_checkbox(
746 client,
747 session_id,
748 ref_map,
749 selector_or_ref,
750 iframe_sessions,
751 )
752 .await?;
753 }
754 }
755 Ok(())
756}
757
758pub async fn uncheck(
759 client: &CdpClient,
760 session_id: &str,
761 ref_map: &RefMap,
762 selector_or_ref: &str,
763 iframe_sessions: &HashMap<String, String>,
764) -> Result<(), String> {
765 let is_checked = super::element::is_element_checked(
766 client,
767 session_id,
768 ref_map,
769 selector_or_ref,
770 iframe_sessions,
771 )
772 .await?;
773 if is_checked {
774 click(
775 client,
776 session_id,
777 ref_map,
778 selector_or_ref,
779 "left",
780 1,
781 iframe_sessions,
782 )
783 .await?;
784
785 if super::element::is_element_checked(
787 client,
788 session_id,
789 ref_map,
790 selector_or_ref,
791 iframe_sessions,
792 )
793 .await?
794 {
795 js_click_checkbox(
796 client,
797 session_id,
798 ref_map,
799 selector_or_ref,
800 iframe_sessions,
801 )
802 .await?;
803 }
804 }
805 Ok(())
806}
807
808async fn js_click_checkbox(
818 client: &CdpClient,
819 session_id: &str,
820 ref_map: &RefMap,
821 selector_or_ref: &str,
822 iframe_sessions: &HashMap<String, String>,
823) -> Result<(), String> {
824 let (object_id, effective_session_id) = resolve_element_object_id(
825 client,
826 session_id,
827 ref_map,
828 selector_or_ref,
829 iframe_sessions,
830 )
831 .await?;
832
833 let js = r#"function() {
834 var el = this;
835 var tag = el.tagName && el.tagName.toUpperCase();
836 // 1. Native input — click it directly
837 if (tag === 'INPUT' && (el.type === 'checkbox' || el.type === 'radio')) {
838 el.click();
839 return;
840 }
841 // 2. Follow label → control association
842 var label = tag === 'LABEL' ? el : (el.closest && el.closest('label'));
843 if (label && label.tagName && label.tagName.toUpperCase() === 'LABEL' && label.control) {
844 label.control.click();
845 return;
846 }
847 // 3. Nested native input
848 var input = el.querySelector && el.querySelector('input[type="checkbox"], input[type="radio"]');
849 if (input) {
850 input.click();
851 return;
852 }
853 // 4. ARIA role control — click the element itself
854 el.click();
855 }"#;
856
857 client
858 .send_command_typed::<_, Value>(
859 "Runtime.callFunctionOn",
860 &CallFunctionOnParams {
861 function_declaration: js.to_string(),
862 object_id: Some(object_id),
863 arguments: None,
864 return_by_value: Some(true),
865 await_promise: Some(false),
866 },
867 Some(&effective_session_id),
868 )
869 .await?;
870
871 Ok(())
872}
873
874pub async fn focus(
875 client: &CdpClient,
876 session_id: &str,
877 ref_map: &RefMap,
878 selector_or_ref: &str,
879 iframe_sessions: &HashMap<String, String>,
880) -> Result<(), String> {
881 let (object_id, effective_session_id) = resolve_element_object_id(
882 client,
883 session_id,
884 ref_map,
885 selector_or_ref,
886 iframe_sessions,
887 )
888 .await?;
889
890 client
891 .send_command_typed::<_, Value>(
892 "Runtime.callFunctionOn",
893 &CallFunctionOnParams {
894 function_declaration: "function() { this.focus(); }".to_string(),
895 object_id: Some(object_id),
896 arguments: None,
897 return_by_value: Some(true),
898 await_promise: Some(false),
899 },
900 Some(&effective_session_id),
901 )
902 .await?;
903
904 Ok(())
905}
906
907pub async fn clear(
908 client: &CdpClient,
909 session_id: &str,
910 ref_map: &RefMap,
911 selector_or_ref: &str,
912 iframe_sessions: &HashMap<String, String>,
913) -> Result<(), String> {
914 let (object_id, effective_session_id) = resolve_element_object_id(
915 client,
916 session_id,
917 ref_map,
918 selector_or_ref,
919 iframe_sessions,
920 )
921 .await?;
922
923 client
924 .send_command_typed::<_, Value>(
925 "Runtime.callFunctionOn",
926 &CallFunctionOnParams {
927 function_declaration: r#"function() {
928 this.focus();
929 this.value = '';
930 this.dispatchEvent(new Event('input', { bubbles: true }));
931 this.dispatchEvent(new Event('change', { bubbles: true }));
932 }"#
933 .to_string(),
934 object_id: Some(object_id),
935 arguments: None,
936 return_by_value: Some(true),
937 await_promise: Some(false),
938 },
939 Some(&effective_session_id),
940 )
941 .await?;
942
943 Ok(())
944}
945
946pub async fn select_all(
947 client: &CdpClient,
948 session_id: &str,
949 ref_map: &RefMap,
950 selector_or_ref: &str,
951 iframe_sessions: &HashMap<String, String>,
952) -> Result<(), String> {
953 let (object_id, effective_session_id) = resolve_element_object_id(
954 client,
955 session_id,
956 ref_map,
957 selector_or_ref,
958 iframe_sessions,
959 )
960 .await?;
961
962 client
963 .send_command_typed::<_, Value>(
964 "Runtime.callFunctionOn",
965 &CallFunctionOnParams {
966 function_declaration: r#"function() {
967 this.focus();
968 if (typeof this.select === 'function') {
969 this.select();
970 } else {
971 const range = document.createRange();
972 range.selectNodeContents(this);
973 const sel = window.getSelection();
974 sel.removeAllRanges();
975 sel.addRange(range);
976 }
977 }"#
978 .to_string(),
979 object_id: Some(object_id),
980 arguments: None,
981 return_by_value: Some(true),
982 await_promise: Some(false),
983 },
984 Some(&effective_session_id),
985 )
986 .await?;
987
988 Ok(())
989}
990
991pub async fn scroll_into_view(
992 client: &CdpClient,
993 session_id: &str,
994 ref_map: &RefMap,
995 selector_or_ref: &str,
996 iframe_sessions: &HashMap<String, String>,
997) -> Result<(), String> {
998 let (object_id, effective_session_id) = resolve_element_object_id(
999 client,
1000 session_id,
1001 ref_map,
1002 selector_or_ref,
1003 iframe_sessions,
1004 )
1005 .await?;
1006
1007 client
1008 .send_command_typed::<_, Value>(
1009 "Runtime.callFunctionOn",
1010 &CallFunctionOnParams {
1011 function_declaration:
1012 "function() { this.scrollIntoView({ block: 'center', inline: 'center' }); }"
1013 .to_string(),
1014 object_id: Some(object_id),
1015 arguments: None,
1016 return_by_value: Some(true),
1017 await_promise: Some(false),
1018 },
1019 Some(&effective_session_id),
1020 )
1021 .await?;
1022
1023 Ok(())
1024}
1025
1026pub async fn dispatch_event(
1027 client: &CdpClient,
1028 session_id: &str,
1029 ref_map: &RefMap,
1030 selector_or_ref: &str,
1031 event_type: &str,
1032 event_init: Option<&Value>,
1033 iframe_sessions: &HashMap<String, String>,
1034) -> Result<(), String> {
1035 let (object_id, effective_session_id) = resolve_element_object_id(
1036 client,
1037 session_id,
1038 ref_map,
1039 selector_or_ref,
1040 iframe_sessions,
1041 )
1042 .await?;
1043
1044 let init_json = event_init
1045 .map(|v| serde_json::to_string(v).unwrap_or("{}".to_string()))
1046 .unwrap_or_else(|| "{ bubbles: true }".to_string());
1047
1048 let js = format!(
1049 "function() {{ this.dispatchEvent(new Event({}, {})); }}",
1050 serde_json::to_string(event_type).unwrap_or_default(),
1051 init_json
1052 );
1053
1054 client
1055 .send_command_typed::<_, Value>(
1056 "Runtime.callFunctionOn",
1057 &CallFunctionOnParams {
1058 function_declaration: js,
1059 object_id: Some(object_id),
1060 arguments: None,
1061 return_by_value: Some(true),
1062 await_promise: Some(false),
1063 },
1064 Some(&effective_session_id),
1065 )
1066 .await?;
1067
1068 Ok(())
1069}
1070
1071pub async fn highlight(
1072 client: &CdpClient,
1073 session_id: &str,
1074 ref_map: &RefMap,
1075 selector_or_ref: &str,
1076 iframe_sessions: &HashMap<String, String>,
1077) -> Result<(), String> {
1078 let (object_id, effective_session_id) = resolve_element_object_id(
1079 client,
1080 session_id,
1081 ref_map,
1082 selector_or_ref,
1083 iframe_sessions,
1084 )
1085 .await?;
1086
1087 client
1088 .send_command_typed::<_, Value>(
1089 "Runtime.callFunctionOn",
1090 &CallFunctionOnParams {
1091 function_declaration: r#"function() {
1092 this.style.outline = '2px solid red';
1093 this.style.outlineOffset = '2px';
1094 const el = this;
1095 setTimeout(() => {
1096 el.style.outline = '';
1097 el.style.outlineOffset = '';
1098 }, 3000);
1099 }"#
1100 .to_string(),
1101 object_id: Some(object_id),
1102 arguments: None,
1103 return_by_value: Some(true),
1104 await_promise: Some(false),
1105 },
1106 Some(&effective_session_id),
1107 )
1108 .await?;
1109
1110 Ok(())
1111}
1112
1113pub async fn tap_touch(
1114 client: &CdpClient,
1115 session_id: &str,
1116 ref_map: &RefMap,
1117 selector_or_ref: &str,
1118 iframe_sessions: &HashMap<String, String>,
1119) -> Result<(), String> {
1120 let (x, y, effective_session_id) = resolve_element_center(
1121 client,
1122 session_id,
1123 ref_map,
1124 selector_or_ref,
1125 iframe_sessions,
1126 )
1127 .await?;
1128
1129 client
1130 .send_command(
1131 "Input.dispatchTouchEvent",
1132 Some(serde_json::json!({
1133 "type": "touchStart",
1134 "touchPoints": [{ "x": x, "y": y }],
1135 })),
1136 Some(&effective_session_id),
1137 )
1138 .await?;
1139
1140 client
1141 .send_command(
1142 "Input.dispatchTouchEvent",
1143 Some(serde_json::json!({
1144 "type": "touchEnd",
1145 "touchPoints": [],
1146 })),
1147 Some(&effective_session_id),
1148 )
1149 .await?;
1150
1151 Ok(())
1152}
1153
1154async fn dispatch_mouse_or_dialog(
1161 client: &CdpClient,
1162 session_id: &str,
1163 accept_sessions: &[&str],
1164 params: &DispatchMouseEventParams,
1165) -> Result<bool, String> {
1166 use tokio::sync::broadcast::error::RecvError;
1167
1168 let mut events = client.subscribe();
1170 let send =
1171 client.send_command_typed::<_, Value>("Input.dispatchMouseEvent", params, Some(session_id));
1172 tokio::pin!(send);
1173 loop {
1174 tokio::select! {
1175 res = &mut send => {
1176 res?;
1177 return Ok(false);
1178 }
1179 event = events.recv() => {
1180 match event {
1181 Ok(e) if e.method == "Page.javascriptDialogOpening" => {
1182 let ours = match e.session_id.as_deref() {
1187 Some(sid) => accept_sessions.contains(&sid),
1188 None => true,
1189 };
1190 if ours {
1191 return Ok(true);
1192 }
1193 continue;
1194 }
1195 Ok(_) => continue,
1196 Err(RecvError::Lagged(_)) => continue,
1197 Err(RecvError::Closed) => {
1198 (&mut send).await?;
1199 return Ok(false);
1200 }
1201 }
1202 }
1203 }
1204 }
1205}
1206
1207async fn dispatch_click(
1208 client: &CdpClient,
1209 session_id: &str,
1210 accept_sessions: &[&str],
1211 x: f64,
1212 y: f64,
1213 button: &str,
1214 click_count: i32,
1215) -> Result<ClickResult, String> {
1216 if dispatch_mouse_or_dialog(
1218 client,
1219 session_id,
1220 accept_sessions,
1221 &DispatchMouseEventParams {
1222 event_type: "mouseMoved".to_string(),
1223 x,
1224 y,
1225 button: None,
1226 buttons: None,
1227 click_count: None,
1228 delta_x: None,
1229 delta_y: None,
1230 modifiers: None,
1231 },
1232 )
1233 .await?
1234 {
1235 return Ok(ClickResult {
1237 dialog_opened: true,
1238 pending_release: None,
1239 });
1240 }
1241
1242 let button_value = match button {
1243 "right" => 2,
1244 "middle" => 4,
1245 _ => 1,
1246 };
1247
1248 if dispatch_mouse_or_dialog(
1250 client,
1251 session_id,
1252 accept_sessions,
1253 &DispatchMouseEventParams {
1254 event_type: "mousePressed".to_string(),
1255 x,
1256 y,
1257 button: Some(button.to_string()),
1258 buttons: Some(button_value),
1259 click_count: Some(click_count),
1260 delta_x: None,
1261 delta_y: None,
1262 modifiers: None,
1263 },
1264 )
1265 .await?
1266 {
1267 return Ok(ClickResult {
1271 dialog_opened: true,
1272 pending_release: Some(PendingRelease {
1273 session_id: session_id.to_string(),
1274 x,
1275 y,
1276 button: button.to_string(),
1277 }),
1278 });
1279 }
1280
1281 let dialog_opened = dispatch_mouse_or_dialog(
1284 client,
1285 session_id,
1286 accept_sessions,
1287 &DispatchMouseEventParams {
1288 event_type: "mouseReleased".to_string(),
1289 x,
1290 y,
1291 button: Some(button.to_string()),
1292 buttons: Some(0),
1293 click_count: Some(click_count),
1294 delta_x: None,
1295 delta_y: None,
1296 modifiers: None,
1297 },
1298 )
1299 .await?;
1300 Ok(ClickResult {
1301 dialog_opened,
1302 pending_release: None,
1303 })
1304}
1305
1306pub async fn dispatch_pending_release(
1309 client: &CdpClient,
1310 release: &PendingRelease,
1311) -> Result<(), String> {
1312 client
1313 .send_command_typed::<_, Value>(
1314 "Input.dispatchMouseEvent",
1315 &DispatchMouseEventParams {
1316 event_type: "mouseReleased".to_string(),
1317 x: release.x,
1318 y: release.y,
1319 button: Some(release.button.clone()),
1320 buttons: Some(0),
1321 click_count: Some(1),
1322 delta_x: None,
1323 delta_y: None,
1324 modifiers: None,
1325 },
1326 Some(&release.session_id),
1327 )
1328 .await?;
1329 Ok(())
1330}
1331
1332fn char_to_key_info(ch: char) -> (String, String, i32) {
1333 match ch {
1334 '\n' | '\r' => ("Enter".to_string(), "Enter".to_string(), 13),
1335 '\t' => ("Tab".to_string(), "Tab".to_string(), 9),
1336 ' ' => (" ".to_string(), "Space".to_string(), 32),
1337 _ => {
1338 let key = ch.to_string();
1339 if ch.is_ascii_alphabetic() {
1340 let upper = ch.to_ascii_uppercase();
1342 let code = format!("Key{}", upper);
1343 let key_code = upper as i32;
1344 (key, code, key_code)
1345 } else if ch.is_ascii_digit() {
1346 let code = format!("Digit{}", ch);
1347 let key_code = ch as i32;
1348 (key, code, key_code)
1349 } else {
1350 let (code, key_code) = punctuation_key_info(ch);
1351 (key, code.to_string(), key_code)
1352 }
1353 }
1354 }
1355}
1356
1357fn punctuation_key_info(ch: char) -> (&'static str, i32) {
1365 match ch {
1366 ';' | ':' => ("Semicolon", 186),
1368 '=' | '+' => ("Equal", 187),
1370 ',' | '<' => ("Comma", 188),
1372 '-' | '_' => ("Minus", 189),
1374 '.' | '>' => ("Period", 190),
1376 '/' | '?' => ("Slash", 191),
1378 '`' | '~' => ("Backquote", 192),
1380 '[' | '{' => ("BracketLeft", 219),
1382 '\\' | '|' => ("Backslash", 220),
1384 ']' | '}' => ("BracketRight", 221),
1386 '\'' | '"' => ("Quote", 222),
1388 _ => ("", 0),
1389 }
1390}
1391
1392fn key_text(key_name: &str) -> Option<String> {
1397 match key_name {
1398 "Enter" => Some("\r".to_string()),
1399 "Tab" => Some("\t".to_string()),
1400 " " => Some(" ".to_string()),
1401 _ => {
1402 if key_name.len() == 1 {
1404 Some(key_name.to_string())
1405 } else {
1406 None
1407 }
1408 }
1409 }
1410}
1411
1412fn named_key_info(key: &str) -> (String, String, i32) {
1413 match key.to_lowercase().as_str() {
1414 "enter" | "return" => ("Enter".to_string(), "Enter".to_string(), 13),
1415 "tab" => ("Tab".to_string(), "Tab".to_string(), 9),
1416 "escape" | "esc" => ("Escape".to_string(), "Escape".to_string(), 27),
1417 "backspace" => ("Backspace".to_string(), "Backspace".to_string(), 8),
1418 "delete" => ("Delete".to_string(), "Delete".to_string(), 46),
1419 "arrowup" | "up" => ("ArrowUp".to_string(), "ArrowUp".to_string(), 38),
1420 "arrowdown" | "down" => ("ArrowDown".to_string(), "ArrowDown".to_string(), 40),
1421 "arrowleft" | "left" => ("ArrowLeft".to_string(), "ArrowLeft".to_string(), 37),
1422 "arrowright" | "right" => ("ArrowRight".to_string(), "ArrowRight".to_string(), 39),
1423 "home" => ("Home".to_string(), "Home".to_string(), 36),
1424 "end" => ("End".to_string(), "End".to_string(), 35),
1425 "pageup" => ("PageUp".to_string(), "PageUp".to_string(), 33),
1426 "pagedown" => ("PageDown".to_string(), "PageDown".to_string(), 34),
1427 "space" | " " => (" ".to_string(), "Space".to_string(), 32),
1428 _ => {
1429 if key.len() == 1 {
1430 let ch = key.chars().next().unwrap();
1431 char_to_key_info(ch)
1432 } else {
1433 (key.to_string(), key.to_string(), 0)
1434 }
1435 }
1436 }
1437}
1438
1439#[cfg(test)]
1440mod tests {
1441 use super::*;
1442
1443 #[test]
1449 fn test_char_to_key_info_matches_playwright_layout() {
1450 let cases: &[(char, &str, i32)] = &[
1452 ('a', "KeyA", 65),
1454 ('z', "KeyZ", 90),
1455 ('A', "KeyA", 65),
1456 ('0', "Digit0", 48),
1458 ('9', "Digit9", 57),
1459 ('.', "Period", 190),
1462 (',', "Comma", 188),
1463 ('/', "Slash", 191),
1464 (';', "Semicolon", 186),
1465 ('\'', "Quote", 222),
1466 ('[', "BracketLeft", 219),
1467 (']', "BracketRight", 221),
1468 ('\\', "Backslash", 220),
1469 ('`', "Backquote", 192),
1470 ('-', "Minus", 189),
1471 ('=', "Equal", 187),
1472 ('>', "Period", 190),
1474 ('<', "Comma", 188),
1475 ('?', "Slash", 191),
1476 (':', "Semicolon", 186),
1477 ('"', "Quote", 222),
1478 ('{', "BracketLeft", 219),
1479 ('}', "BracketRight", 221),
1480 ('|', "Backslash", 220),
1481 ('~', "Backquote", 192),
1482 ('_', "Minus", 189),
1483 ('+', "Equal", 187),
1484 (' ', "Space", 32),
1486 ('\n', "Enter", 13),
1487 ('\t', "Tab", 9),
1488 ];
1489
1490 for &(ch, expected_code, expected_vk) in cases {
1491 let (key, code, vk) = char_to_key_info(ch);
1492 assert_eq!(
1493 code, expected_code,
1494 "char {:?}: expected code {:?}, got {:?}",
1495 ch, expected_code, code
1496 );
1497 assert_eq!(
1498 vk, expected_vk,
1499 "char {:?}: expected VK {}, got {} (ASCII would be {})",
1500 ch, expected_vk, vk, ch as i32
1501 );
1502 if !ch.is_control() {
1504 assert_eq!(key, ch.to_string(), "char {:?}: key mismatch", ch);
1505 }
1506 }
1507 }
1508
1509 #[test]
1511 fn test_period_is_not_vk_delete() {
1512 let (_, _, vk) = char_to_key_info('.');
1513 assert_ne!(
1514 vk, 46,
1515 "Period must not use VK code 46 (VK_DELETE); expected 190 (VK_OEM_PERIOD)"
1516 );
1517 assert_eq!(vk, 190);
1518 }
1519
1520 #[test]
1523 fn test_unmapped_chars_return_zero_keycode() {
1524 for ch in ['@', '#', '$', '%', '^', '&', '*', '(', ')', '€', '£', '你'] {
1525 let (key, code, vk) = char_to_key_info(ch);
1526 assert_eq!(
1527 code, "",
1528 "char {:?}: unmapped char should have empty code, got {:?}",
1529 ch, code
1530 );
1531 assert_eq!(
1532 vk, 0,
1533 "char {:?}: unmapped char should have VK 0, got {}",
1534 ch, vk
1535 );
1536 assert_eq!(key, ch.to_string());
1537 }
1538 }
1539
1540 #[test]
1541 fn test_key_text_returns_correct_text_for_special_keys() {
1542 assert_eq!(key_text("Enter"), Some("\r".to_string()));
1543 assert_eq!(key_text("Tab"), Some("\t".to_string()));
1544 assert_eq!(key_text(" "), Some(" ".to_string()));
1545 assert_eq!(key_text("a"), Some("a".to_string()));
1547 assert_eq!(key_text("Z"), Some("Z".to_string()));
1548 assert_eq!(key_text("Escape"), None);
1550 assert_eq!(key_text("ArrowUp"), None);
1551 assert_eq!(key_text("Backspace"), None);
1552 assert_eq!(key_text("Delete"), None);
1553 }
1554
1555 #[test]
1557 fn fill_smart_mode_tokens_documented() {
1558 for mode in [
1559 "select",
1560 "checkbox",
1561 "radio",
1562 "text",
1563 "textarea",
1564 "contenteditable",
1565 ] {
1566 assert!(!mode.is_empty());
1567 }
1568 assert!("true".parse::<bool>().unwrap());
1570 assert!(!"false".parse::<bool>().unwrap());
1571 }
1572}