Skip to main content

browser_automation_cli/native/
interaction.rs

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/// Outcome of a click. `dialog_opened` is true if a JavaScript dialog opened
11/// mid-sequence (the page is then blocked until `dialog accept`/`dismiss`).
12/// `pending_release` is set only when the dialog opened after mousePressed but
13/// before mouseReleased: the button is logically held until the caller
14/// dispatches the release (done once the dialog is resolved), otherwise the
15/// next click would register as a drag or double-click.
16#[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    // A click-triggered dialog can fire on the frame's own session (OOPIF) or
47    // on the top-level page session; both count as "ours". A dialog on any
48    // other session belongs to a background tab and must not abort this click.
49    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
115/// Drag from one element center to another via CDP mouse events.
116pub 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    // Focus the element
198    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    // Select all + delete to clear
213    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    // Insert text (keyboard input dispatched at page level, use parent session_id)
233    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
246/// Smart fill matching DevTools agent `fill` semantics:
247/// - `<select>` → option match by value or label
248/// - checkbox/radio → `"true"`/`"false"` (radio true clicks to select)
249/// - otherwise → text fill via insertText
250pub 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                // Radio true: force select via click path used by check()
304                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
385/// Click at page coordinates (vision / experimental path).
386pub 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    // Focus
418    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            // VS Code/Electron webviews reject repeated dispatchKeyEvent calls
503            // carrying printable `text`. Insert printable characters directly
504            // and reserve key events for controls like Enter and Tab.
505            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
528/// Dispatch a keyDown+keyUp sequence for `key` with an optional CDP modifier bitmask.
529///
530/// Modifier values follow the CDP `Input.dispatchKeyEvent` spec:
531/// 1 = Alt, 2 = Control, 4 = Meta (Cmd), 8 = Shift.
532///
533/// Callers that need a platform-appropriate modifier (e.g. Cmd on macOS,
534/// Ctrl elsewhere) must choose the value themselves -- see `cfg!(target_os)`.
535pub 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    // Suppress text insertion when Control (2) or Meta (4) modifiers are active,
544    // since these are command chords (e.g. Ctrl+A = select-all), not text input.
545    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    // Matching nothing must be an error, not a silent success: an agent that
659    // selects a misspelled option otherwise sees "Done", and only discovers
660    // the page state is wrong after more commands. List what was available.
661    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        // Verify the click changed the state (Playwright parity: _setChecked re-checks).
734        // If the coordinate-based click missed (e.g. hidden input, overlay), retry
735        // with a JS .click() on the element and its associated input.
736        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        // Same verify-and-retry as check().
786        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
808/// Fallback for when the coordinate-based CDP click did not toggle the
809/// checkbox/radio state. This mirrors how Playwright dispatches clicks
810/// through the DOM rather than via raw Input.dispatchMouseEvent coordinates.
811///
812/// Uses the same follow-label resolution as `is_element_checked`:
813/// 1. If the element is a native input → `.click()` it directly.
814/// 2. If the element is inside a `<label>` → `.click()` the label's `.control`.
815/// 3. If the element has a nested `<input>` → `.click()` that input.
816/// 4. Otherwise → `.click()` the element itself (handles ARIA role controls).
817async 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
1154/// Dispatches one mouse event and waits for the browser to ack it, but
1155/// returns Ok(true) if a JavaScript dialog opens first. A synchronous dialog
1156/// (confirm/prompt/alert in the event handler) blocks the renderer's main
1157/// thread, so the input ack cannot arrive until the dialog is resolved;
1158/// without this the command hangs until the client read timeout and the agent
1159/// never sees the pending-dialog warning.
1160async 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    // Subscribe before sending so the dialog event cannot slip past us.
1169    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                        // Only a dialog on this click's frame/page session
1183                        // aborts it; a background-tab dialog must not. A
1184                        // session-less event has no flat session and is
1185                        // treated as the top-level page (i.e. ours).
1186                        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    // Move
1217    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        // No button was pressed yet, nothing to release.
1236        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    // Press
1249    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        // Dialog opened from the mousedown handler: the button is held and the
1268        // release will never arrive on its own. Hand the caller what it needs
1269        // to release once the dialog is resolved.
1270        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    // Release. A dialog here fired from the click/mouseup handler, which runs
1282    // after the button is already up, so there is nothing left to release.
1283    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
1306/// Best-effort mouseReleased to clear a button left logically down when a
1307/// dialog opened mid-click. Called after the dialog is resolved.
1308pub 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                // For letters the Windows VK code equals the uppercase ASCII value.
1341                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
1357/// Return the DOM `KeyboardEvent.code` value and Windows virtual-key code for
1358/// a punctuation / symbol character assuming a US keyboard layout.
1359///
1360/// The Windows virtual-key codes (VK_OEM_*) differ from ASCII values for
1361/// punctuation.  Using the raw ASCII code would misidentify characters – e.g.
1362/// '.' (ASCII 46) collides with VK_DELETE (0x2E = 46), causing the period to
1363/// be swallowed.
1364fn punctuation_key_info(ch: char) -> (&'static str, i32) {
1365    match ch {
1366        // VK_OEM_1 (0xBA = 186) — ";:" key on US layout
1367        ';' | ':' => ("Semicolon", 186),
1368        // VK_OEM_PLUS (0xBB = 187) — "=+" key
1369        '=' | '+' => ("Equal", 187),
1370        // VK_OEM_COMMA (0xBC = 188) — ",<" key
1371        ',' | '<' => ("Comma", 188),
1372        // VK_OEM_MINUS (0xBD = 189) — "-_" key
1373        '-' | '_' => ("Minus", 189),
1374        // VK_OEM_PERIOD (0xBE = 190) — ".>" key
1375        '.' | '>' => ("Period", 190),
1376        // VK_OEM_2 (0xBF = 191) — "/?" key
1377        '/' | '?' => ("Slash", 191),
1378        // VK_OEM_3 (0xC0 = 192) — "`~" key
1379        '`' | '~' => ("Backquote", 192),
1380        // VK_OEM_4 (0xDB = 219) — "[{" key
1381        '[' | '{' => ("BracketLeft", 219),
1382        // VK_OEM_5 (0xDC = 220) — "\\|" key
1383        '\\' | '|' => ("Backslash", 220),
1384        // VK_OEM_6 (0xDD = 221) — "]}" key
1385        ']' | '}' => ("BracketRight", 221),
1386        // VK_OEM_7 (0xDE = 222) — "'\""" key
1387        '\'' | '"' => ("Quote", 222),
1388        _ => ("", 0),
1389    }
1390}
1391
1392/// Return the `text` value that CDP `Input.dispatchKeyEvent` needs on the
1393/// `keyDown` event so that Chrome performs the default action for the key.
1394/// For example Enter needs `"\r"` to actually submit a form, and Tab needs
1395/// `"\t"` to move focus.  Non-printable / navigation keys return `None`.
1396fn 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            // Single printable characters carry themselves as text.
1403            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    /// Verify that `char_to_key_info` returns the correct (key, code,
1444    /// windowsVirtualKeyCode) triple for every character in Playwright's
1445    /// USKeyboardLayout.  The expected values below are taken verbatim from
1446    /// playwright-core/lib/server/usKeyboardLayout.js so that any drift from
1447    /// Playwright's behaviour is caught immediately.
1448    #[test]
1449    fn test_char_to_key_info_matches_playwright_layout() {
1450        // (character, expected_code, expected_vk_code)
1451        let cases: &[(char, &str, i32)] = &[
1452            // Letters – VK code must equal the uppercase ASCII value.
1453            ('a', "KeyA", 65),
1454            ('z', "KeyZ", 90),
1455            ('A', "KeyA", 65),
1456            // Digits
1457            ('0', "Digit0", 48),
1458            ('9', "Digit9", 57),
1459            // Punctuation – these are the values from Playwright's layout.
1460            // The bug that prompted this test sent '.' as VK 46 (= VK_DELETE).
1461            ('.', "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            // Shifted variants produced by the same physical keys.
1473            ('>', "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            // Whitespace / control
1485            (' ', "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            // key should be the character itself (except control chars).
1503            if !ch.is_control() {
1504                assert_eq!(key, ch.to_string(), "char {:?}: key mismatch", ch);
1505            }
1506        }
1507    }
1508
1509    /// Regression test: period must NEVER map to VK 46 (VK_DELETE).
1510    #[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    /// Characters outside the US keyboard layout should return (key, "", 0)
1521    /// so that `type_text` falls back to `Input.insertText`.
1522    #[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        // Single printable characters carry themselves.
1546        assert_eq!(key_text("a"), Some("a".to_string()));
1547        assert_eq!(key_text("Z"), Some("Z".to_string()));
1548        // Non-printable named keys return None.
1549        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    /// Smart-fill mode tokens accepted by `fill_smart` branch match.
1556    #[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        // true/false semantics for checkbox/radio
1569        assert!("true".parse::<bool>().unwrap());
1570        assert!(!"false".parse::<bool>().unwrap());
1571    }
1572}