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