Skip to main content

browser_automation_cli/native/
interaction.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2//! Pointer, keyboard, form, and dialog interaction over CDP.
3//!
4//! # Workload
5//!
6//! **I/O-bound ordered:** clicks, keys, type (char-a-char), drag paths, and
7//! fill-form fields **must** stay sequential on one Page (N-135/N-141). Parallel
8//! input races focus and breaks DOM semantics. No JoinSet fan-out here.
9#![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/// Outcome of a click. `dialog_opened` is true if a JavaScript dialog opened
19/// mid-sequence (the page is then blocked until `dialog accept`/`dismiss`).
20/// `pending_release` is set only when the dialog opened after mousePressed but
21/// before mouseReleased: the button is logically held until the caller
22/// dispatches the release (done once the dialog is resolved), otherwise the
23/// next click would register as a drag or double-click.
24#[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    // A click-triggered dialog can fire on the frame's own session (OOPIF) or
55    // on the top-level page session; both count as "ours". A dialog on any
56    // other session belongs to a background tab and must not abort this click.
57    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
123/// Drag from one element center to another via CDP mouse events.
124pub 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    // Focus the element
206    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    // Select all + delete to clear
221    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    // Insert text (keyboard input dispatched at page level, use parent session_id)
241    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
254/// Smart fill matching DevTools agent `fill` semantics:
255/// - `<select>` → option match by value or label
256/// - checkbox/radio → `"true"`/`"false"` (radio true clicks to select)
257/// - otherwise → text fill via insertText
258pub 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                // Radio true: force select via click path used by check()
312                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
393/// Click at page coordinates (vision / experimental path).
394pub 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    // Focus
426    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            // VS Code/Electron webviews reject repeated dispatchKeyEvent calls
511            // carrying printable `text`. Insert printable characters directly
512            // and reserve key events for controls like Enter and Tab.
513            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
536/// Dispatch a keyDown+keyUp sequence for `key` with an optional CDP modifier bitmask.
537///
538/// Modifier values follow the CDP `Input.dispatchKeyEvent` spec:
539/// 1 = Alt, 2 = Control, 4 = Meta (Cmd), 8 = Shift.
540///
541/// Callers that need a platform-appropriate modifier (e.g. Cmd on macOS,
542/// Ctrl elsewhere) must choose the value themselves -- see `cfg!(target_os)`.
543pub 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    // Suppress text insertion when Control (2) or Meta (4) modifiers are active,
552    // since these are command chords (e.g. Ctrl+A = select-all), not text input.
553    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    // Matching nothing must be an error, not a silent success: an agent that
667    // selects a misspelled option otherwise sees "Done", and only discovers
668    // the page state is wrong after more commands. List what was available.
669    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        // Verify the click changed the state (Playwright parity: _setChecked re-checks).
742        // If the coordinate-based click missed (e.g. hidden input, overlay), retry
743        // with a JS .click() on the element and its associated input.
744        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        // Same verify-and-retry as check().
794        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
816/// Fallback for when the coordinate-based CDP click did not toggle the
817/// checkbox/radio state. This mirrors how Playwright dispatches clicks
818/// through the DOM rather than via raw Input.dispatchMouseEvent coordinates.
819///
820/// Uses the same follow-label resolution as `is_element_checked`:
821/// 1. If the element is a native input → `.click()` it directly.
822/// 2. If the element is inside a `<label>` → `.click()` the label's `.control`.
823/// 3. If the element has a nested `<input>` → `.click()` that input.
824/// 4. Otherwise → `.click()` the element itself (handles ARIA role controls).
825async 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
1162/// Dispatches one mouse event and waits for the browser to ack it, but
1163/// returns Ok(true) if a JavaScript dialog opens first. A synchronous dialog
1164/// (confirm/prompt/alert in the event handler) blocks the renderer's main
1165/// thread, so the input ack cannot arrive until the dialog is resolved;
1166/// without this the command hangs until the client read timeout and the agent
1167/// never sees the pending-dialog warning.
1168async 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    // Subscribe before sending so the dialog event cannot slip past us.
1177    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                        // Only a dialog on this click's frame/page session
1191                        // aborts it; a background-tab dialog must not. A
1192                        // session-less event has no flat session and is
1193                        // treated as the top-level page (i.e. ours).
1194                        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    // Move
1225    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        // No button was pressed yet, nothing to release.
1244        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    // Press
1257    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        // Dialog opened from the mousedown handler: the button is held and the
1276        // release will never arrive on its own. Hand the caller what it needs
1277        // to release once the dialog is resolved.
1278        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    // Release. A dialog here fired from the click/mouseup handler, which runs
1290    // after the button is already up, so there is nothing left to release.
1291    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
1314/// Best-effort mouseReleased to clear a button left logically down when a
1315/// dialog opened mid-click. Called after the dialog is resolved.
1316pub 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                // For letters the Windows VK code equals the uppercase ASCII value.
1349                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
1365/// Return the DOM `KeyboardEvent.code` value and Windows virtual-key code for
1366/// a punctuation / symbol character assuming a US keyboard layout.
1367///
1368/// The Windows virtual-key codes (VK_OEM_*) differ from ASCII values for
1369/// punctuation.  Using the raw ASCII code would misidentify characters – e.g.
1370/// '.' (ASCII 46) collides with VK_DELETE (0x2E = 46), causing the period to
1371/// be swallowed.
1372fn punctuation_key_info(ch: char) -> (&'static str, i32) {
1373    match ch {
1374        // VK_OEM_1 (0xBA = 186) — ";:" key on US layout
1375        ';' | ':' => ("Semicolon", 186),
1376        // VK_OEM_PLUS (0xBB = 187) — "=+" key
1377        '=' | '+' => ("Equal", 187),
1378        // VK_OEM_COMMA (0xBC = 188) — ",<" key
1379        ',' | '<' => ("Comma", 188),
1380        // VK_OEM_MINUS (0xBD = 189) — "-_" key
1381        '-' | '_' => ("Minus", 189),
1382        // VK_OEM_PERIOD (0xBE = 190) — ".>" key
1383        '.' | '>' => ("Period", 190),
1384        // VK_OEM_2 (0xBF = 191) — "/?" key
1385        '/' | '?' => ("Slash", 191),
1386        // VK_OEM_3 (0xC0 = 192) — "`~" key
1387        '`' | '~' => ("Backquote", 192),
1388        // VK_OEM_4 (0xDB = 219) — "[{" key
1389        '[' | '{' => ("BracketLeft", 219),
1390        // VK_OEM_5 (0xDC = 220) — "\\|" key
1391        '\\' | '|' => ("Backslash", 220),
1392        // VK_OEM_6 (0xDD = 221) — "]}" key
1393        ']' | '}' => ("BracketRight", 221),
1394        // VK_OEM_7 (0xDE = 222) — "'\""" key
1395        '\'' | '"' => ("Quote", 222),
1396        _ => ("", 0),
1397    }
1398}
1399
1400/// Return the `text` value that CDP `Input.dispatchKeyEvent` needs on the
1401/// `keyDown` event so that Chrome performs the default action for the key.
1402/// For example Enter needs `"\r"` to actually submit a form, and Tab needs
1403/// `"\t"` to move focus.  Non-printable / navigation keys return `None`.
1404fn 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            // Single printable characters carry themselves as text.
1411            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    /// Verify that `char_to_key_info` returns the correct (key, code,
1455    /// windowsVirtualKeyCode) triple for every character in Playwright's
1456    /// USKeyboardLayout.  The expected values below are taken verbatim from
1457    /// playwright-core/lib/server/usKeyboardLayout.js so that any drift from
1458    /// Playwright's behaviour is caught immediately.
1459    #[test]
1460    fn test_char_to_key_info_matches_playwright_layout() {
1461        // (character, expected_code, expected_vk_code)
1462        let cases: &[(char, &str, i32)] = &[
1463            // Letters – VK code must equal the uppercase ASCII value.
1464            ('a', "KeyA", 65),
1465            ('z', "KeyZ", 90),
1466            ('A', "KeyA", 65),
1467            // Digits
1468            ('0', "Digit0", 48),
1469            ('9', "Digit9", 57),
1470            // Punctuation – these are the values from Playwright's layout.
1471            // The bug that prompted this test sent '.' as VK 46 (= VK_DELETE).
1472            ('.', "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            // Shifted variants produced by the same physical keys.
1484            ('>', "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            // Whitespace / control
1496            (' ', "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            // key should be the character itself (except control chars).
1514            if !ch.is_control() {
1515                assert_eq!(key, ch.to_string(), "char {:?}: key mismatch", ch);
1516            }
1517        }
1518    }
1519
1520    /// Regression test: period must NEVER map to VK 46 (VK_DELETE).
1521    #[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    /// Characters outside the US keyboard layout should return (key, "", 0)
1532    /// so that `type_text` falls back to `Input.insertText`.
1533    #[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        // Single printable characters carry themselves.
1557        assert_eq!(key_text("a"), Some("a".to_string()));
1558        assert_eq!(key_text("Z"), Some("Z".to_string()));
1559        // Non-printable named keys return None.
1560        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    /// Smart-fill mode tokens accepted by `fill_smart` branch match.
1567    #[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        // true/false semantics for checkbox/radio
1580        assert!("true".parse::<bool>().unwrap());
1581        assert!(!"false".parse::<bool>().unwrap());
1582    }
1583}