car-browser 0.51.0

Browser automation and perception pipeline for Common Agent Runtime
//! What a key press has to look like before Chromium will act on it.
//!
//! `Input.dispatchKeyEvent` with only `key` and `modifiers` set delivers a
//! DOM event a page can observe — and nothing else happens. Chromium's
//! editing layer (and its default form handling) keys off
//! `windowsVirtualKeyCode`, and text entry keys off `text`; without them
//! Backspace deletes nothing, the arrows move no caret, and Enter submits no
//! form. That was live-verified: every editing key was delivered, none of
//! them edited.
//!
//! This module is the missing description. It is pure — a key name in, the
//! CDP fields out — which is what makes the behaviour testable without a live
//! Chromium, the same discipline the rest of this crate's CDP glue follows.
//!
//! Not a full keyboard layout: the named keys a browser drawer's user
//! actually presses, plus printable characters derived generically. A key it
//! does not recognise still dispatches with its `key` name, exactly as
//! before — unrecognised keys get no worse, recognised ones get correct.

/// The CDP fields for one key press.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct KeyDescriptor {
    /// DOM `key` value, echoed back as sent.
    pub key: String,
    /// DOM `code` (physical key). `None` when unknown.
    pub code: Option<&'static str>,
    /// `windowsVirtualKeyCode` — what Chromium's editing commands dispatch
    /// on. `None` when unknown.
    pub virtual_key_code: Option<i64>,
    /// The character this press inserts, or `None` for a key that inserts
    /// nothing (Backspace, the arrows, Escape…).
    pub text: Option<String>,
}

/// Describe `key` for CDP.
///
/// `text` is what a press would INSERT, so it is suppressed while Control or
/// Meta is held: ⌘A is "select all", not a request to type the letter a, and
/// sending `text` there makes Chromium do both.
pub fn describe_key(key: &str, control_or_meta_held: bool) -> KeyDescriptor {
    let (canonical, code, vk, text) = named_key(key)
        .or_else(|| printable_key(key))
        .unwrap_or((None, None, None, None));
    KeyDescriptor {
        // The CANONICAL DOM name, not the spelling the caller used. This
        // module accepts aliases — "Return", "Esc", "Left", "Spacebar" — and
        // echoing them back put them on the one field pages actually match:
        // `e.key === 'Enter'` is false for a press described as "Return", so
        // the `code`/`keyCode` were right and every keyboard handler on the
        // page still missed. An unrecognised name falls back to the raw
        // input, which is the best available answer for a key this module
        // does not know.
        key: canonical.unwrap_or_else(|| key.to_string()),
        code,
        virtual_key_code: vk,
        text: if control_or_meta_held { None } else { text },
    }
}

/// Canonical DOM `key`, `code`, virtual key code, inserted text.
type KeyFacts = (
    Option<String>,
    Option<&'static str>,
    Option<i64>,
    Option<String>,
);

/// The named keys a person actually presses in a browser. Virtual key codes
/// are the Windows VK_* values Chromium expects — VK_BACK 8, VK_RETURN 13,
/// VK_LEFT 37, and so on.
fn dom(name: &str) -> Option<String> {
    Some(name.to_string())
}

fn named_key(key: &str) -> Option<KeyFacts> {
    let facts: KeyFacts = match key {
        // Editing. `text` matters for Enter: Chromium's form handling looks
        // for the carriage return, not just the key name.
        "Backspace" => (dom("Backspace"), Some("Backspace"), Some(8), None),
        // No `text`. Tab MOVES FOCUS; the US-layout reference this follows
        // gives it none, and sending "\t" risks typing a literal tab into the
        // field instead of (or as well as) advancing.
        "Tab" => (dom("Tab"), Some("Tab"), Some(9), None),
        "Enter" | "Return" => (
            dom("Enter"),
            Some("Enter"),
            Some(13),
            Some("\r".to_string()),
        ),
        "Escape" | "Esc" => (dom("Escape"), Some("Escape"), Some(27), None),
        "Delete" => (dom("Delete"), Some("Delete"), Some(46), None),
        " " | "Space" | "Spacebar" => (dom(" "), Some("Space"), Some(32), Some(" ".to_string())),
        // Caret movement.
        "ArrowLeft" | "Left" => (dom("ArrowLeft"), Some("ArrowLeft"), Some(37), None),
        "ArrowUp" | "Up" => (dom("ArrowUp"), Some("ArrowUp"), Some(38), None),
        "ArrowRight" | "Right" => (dom("ArrowRight"), Some("ArrowRight"), Some(39), None),
        "ArrowDown" | "Down" => (dom("ArrowDown"), Some("ArrowDown"), Some(40), None),
        "Home" => (dom("Home"), Some("Home"), Some(36), None),
        "End" => (dom("End"), Some("End"), Some(35), None),
        "PageUp" => (dom("PageUp"), Some("PageUp"), Some(33), None),
        "PageDown" => (dom("PageDown"), Some("PageDown"), Some(34), None),
        // Modifiers pressed on their own.
        "Shift" => (dom("Shift"), Some("ShiftLeft"), Some(16), None),
        "Control" => (dom("Control"), Some("ControlLeft"), Some(17), None),
        "Alt" => (dom("Alt"), Some("AltLeft"), Some(18), None),
        "Meta" => (dom("Meta"), Some("MetaLeft"), Some(91), None),
        _ => return None,
    };
    Some(facts)
}

/// A single printable character: its own text, with the virtual key code and
/// `code` Chromium expects for the US layout. Derived rather than tabulated —
/// a table of 62 entries would say the same thing at more length.
fn printable_key(key: &str) -> Option<KeyFacts> {
    let mut chars = key.chars();
    let ch = chars.next()?;
    if chars.next().is_some() {
        // A multi-character name this module does not know — not a
        // printable character.
        return None;
    }
    let text = Some(ch.to_string());
    // Virtual key codes are layout-independent and defined on the UPPERCASE
    // letter: pressing `a` and `A` are both VK 65, told apart by Shift.
    let upper = ch.to_ascii_uppercase();
    let (code, vk) = match upper {
        'A'..='Z' => (
            Some(LETTER_CODES[(upper as u8 - b'A') as usize]),
            Some(upper as i64),
        ),
        '0'..='9' => (
            Some(DIGIT_CODES[(upper as u8 - b'0') as usize]),
            Some(upper as i64),
        ),
        // Punctuation and everything else: Chromium still inserts the
        // character from `text`; only the physical-key description is
        // unknown, and guessing it wrong is worse than omitting it.
        _ => (None, None),
    };
    // A printable character IS its own DOM key name.
    Some((Some(key.to_string()), code, vk, text))
}

const LETTER_CODES: [&str; 26] = [
    "KeyA", "KeyB", "KeyC", "KeyD", "KeyE", "KeyF", "KeyG", "KeyH", "KeyI", "KeyJ", "KeyK", "KeyL",
    "KeyM", "KeyN", "KeyO", "KeyP", "KeyQ", "KeyR", "KeyS", "KeyT", "KeyU", "KeyV", "KeyW", "KeyX",
    "KeyY", "KeyZ",
];

const DIGIT_CODES: [&str; 10] = [
    "Digit0", "Digit1", "Digit2", "Digit3", "Digit4", "Digit5", "Digit6", "Digit7", "Digit8",
    "Digit9",
];

#[cfg(test)]
mod tests {
    /// The aliases this module advertises must reach the page as the DOM
    /// names pages match on. Echoing the caller's spelling put "Return" /
    /// "Esc" / "Left" / "Spacebar" on `key`, so `e.key === 'Enter'` was false
    /// for a press this crate had correctly identified as Enter — right
    /// `code`, right `keyCode`, and every keyboard handler still missed.
    #[test]
    fn every_alias_reaches_the_page_under_its_canonical_dom_name() {
        for (alias, canonical) in [
            ("Return", "Enter"),
            ("Enter", "Enter"),
            ("Esc", "Escape"),
            ("Escape", "Escape"),
            ("Left", "ArrowLeft"),
            ("Up", "ArrowUp"),
            ("Right", "ArrowRight"),
            ("Down", "ArrowDown"),
            ("Space", " "),
            ("Spacebar", " "),
            (" ", " "),
        ] {
            assert_eq!(
                super::describe_key(alias, false).key,
                canonical,
                "{alias} must be delivered as {canonical}"
            );
        }
        // A printable character is its own name, and an unknown name falls
        // back to the raw input rather than becoming nothing.
        assert_eq!(super::describe_key("a", false).key, "a");
        assert_eq!(super::describe_key("F13", false).key, "F13");
    }

    use super::*;

    /// The exact regression: Backspace was delivered as an event and deleted
    /// nothing, because Chromium's editing layer dispatches on
    /// `windowsVirtualKeyCode` and the field was absent.
    #[test]
    fn backspace_carries_the_virtual_key_code_that_makes_it_delete() {
        let d = describe_key("Backspace", false);
        assert_eq!(d.virtual_key_code, Some(8));
        assert_eq!(d.code, Some("Backspace"));
        assert_eq!(d.text, None, "Backspace inserts nothing");
    }

    #[test]
    fn the_arrows_carry_their_virtual_key_codes() {
        for (key, vk) in [
            ("ArrowLeft", 37),
            ("ArrowUp", 38),
            ("ArrowRight", 39),
            ("ArrowDown", 40),
        ] {
            let d = describe_key(key, false);
            assert_eq!(d.virtual_key_code, Some(vk), "{key}");
            assert_eq!(d.text, None, "{key} moves the caret, it does not type");
        }
    }

    /// Enter submitting a form needs the carriage return, not just the name.
    #[test]
    fn enter_carries_both_the_key_code_and_the_carriage_return() {
        let d = describe_key("Enter", false);
        assert_eq!(d.virtual_key_code, Some(13));
        assert_eq!(d.text.as_deref(), Some("\r"));
    }

    /// Tab moves focus — it must not also type a literal tab character into
    /// the field it is leaving.
    #[test]
    fn tab_moves_focus_without_typing_anything() {
        let d = describe_key("Tab", false);
        assert_eq!(d.code, Some("Tab"));
        assert_eq!(d.virtual_key_code, Some(9));
        assert_eq!(d.text, None);
    }

    #[test]
    fn a_printable_letter_types_itself() {
        let d = describe_key("a", false);
        assert_eq!(d.text.as_deref(), Some("a"));
        assert_eq!(d.code, Some("KeyA"));
        assert_eq!(
            d.virtual_key_code,
            Some(65),
            "virtual key codes are defined on the uppercase letter"
        );
        assert_eq!(describe_key("A", false).virtual_key_code, Some(65));
    }

    #[test]
    fn a_digit_types_itself() {
        let d = describe_key("7", false);
        assert_eq!(d.text.as_deref(), Some("7"));
        assert_eq!(d.code, Some("Digit7"));
        assert_eq!(d.virtual_key_code, Some(b'7' as i64));
    }

    /// ⌘A is "select all", not a request to type the letter a. Sending
    /// `text` alongside the modifier makes Chromium do both.
    #[test]
    fn a_shortcut_does_not_also_type_its_letter() {
        let plain = describe_key("a", false);
        let shortcut = describe_key("a", true);
        assert_eq!(plain.text.as_deref(), Some("a"));
        assert_eq!(shortcut.text, None);
        assert_eq!(
            shortcut.virtual_key_code, plain.virtual_key_code,
            "the key itself is unchanged — only what it inserts"
        );
    }

    #[test]
    fn punctuation_still_types_even_without_a_physical_key_description() {
        let d = describe_key("!", false);
        assert_eq!(d.text.as_deref(), Some("!"));
        assert_eq!(d.code, None, "guessing the physical key would be worse");
    }

    /// An unrecognised name is no worse than before: it still dispatches
    /// under its own `key`, which is all the old code ever sent.
    #[test]
    fn an_unknown_key_name_still_dispatches_by_name() {
        let d = describe_key("F13", false);
        assert_eq!(d.key, "F13");
        assert_eq!(d.code, None);
        assert_eq!(d.virtual_key_code, None);
        assert_eq!(d.text, None);
    }

    #[test]
    fn space_is_recognised_by_every_spelling_a_client_might_send() {
        for key in [" ", "Space", "Spacebar"] {
            let d = describe_key(key, false);
            assert_eq!(d.virtual_key_code, Some(32), "{key}");
            assert_eq!(d.text.as_deref(), Some(" "), "{key}");
        }
    }
}