From c092ffd82b790074e0e228dcdff8bae7945539af Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 15 Mar 2026 13:57:15 -0500 Subject: [PATCH] fix: use correct VK codes for punctuation in type command (#836) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: use correct Windows virtual-key codes for punctuation in type command The `type` command was dropping punctuation characters like `.`, `'`, and `#` because `char_to_key_info()` used raw ASCII codes as the `windowsVirtualKeyCode` in CDP `Input.dispatchKeyEvent` calls. For punctuation the ASCII value collides with unrelated VK codes — most critically '.' (ASCII 46) equals VK_DELETE (0x2E), causing Chrome to interpret periods as Delete key presses. Changes: - Add `punctuation_key_info()` with correct VK_OEM_* codes matching Playwright's USKeyboardLayout (e.g. Period=190, Slash=191, Semicolon=186) - Fall back to `Input.insertText` for characters without a US keyboard mapping (emoji, CJK, etc.), matching Playwright's `keyboard.type()` - Update e2e test to use `type` instead of `fill` workaround for email - Add unit tests verifying VK code parity with Playwright's layout Fixes #833 * style: fix rustfmt formatting for InsertTextParams --------- Co-authored-by: ctate <366502+ctate@users.noreply.github.com> --- cli/src/native/e2e_tests.rs | 4 +- cli/src/native/interaction.rs | 234 ++++++++++++++++++++++++++++------ 2 files changed, 197 insertions(+), 41 deletions(-) diff --git a/cli/src/native/e2e_tests.rs b/cli/src/native/e2e_tests.rs index 540b149..2bdb264 100644 --- a/cli/src/native/e2e_tests.rs +++ b/cli/src/native/e2e_tests.rs @@ -406,9 +406,9 @@ async fn e2e_form_interaction() { assert_success(&resp); assert_eq!(get_data(&resp)["result"], "John Doe"); - // Fill email (use fill instead of type to avoid key dispatch issues with '.') + // Type email – the type action now correctly handles punctuation like '.' let resp = execute_command( - &json!({ "id": "12", "action": "fill", "selector": "#email", "value": "john@example.com" }), + &json!({ "id": "12", "action": "type", "selector": "#email", "text": "john@example.com" }), &mut state, ) .await; diff --git a/cli/src/native/interaction.rs b/cli/src/native/interaction.rs index a3d807e..f058023 100644 --- a/cli/src/native/interaction.rs +++ b/cli/src/native/interaction.rs @@ -163,39 +163,53 @@ pub async fn type_text( let text_str = ch.to_string(); let (key, code, key_code) = char_to_key_info(ch); - client - .send_command_typed::<_, Value>( - "Input.dispatchKeyEvent", - &DispatchKeyEventParams { - event_type: "keyDown".to_string(), - key: Some(key.clone()), - code: Some(code.clone()), - text: Some(text_str.clone()), - unmodified_text: Some(text_str.clone()), - windows_virtual_key_code: Some(key_code), - native_virtual_key_code: Some(key_code), - modifiers: None, - }, - Some(session_id), - ) - .await?; + // Characters that have no US-keyboard mapping (key_code == 0 and empty + // code) are inserted via `Input.insertText`, matching Playwright's + // keyboard.type() fallback behaviour. This handles emoji, CJK, and + // other characters that don't correspond to a physical key. + if key_code == 0 && code.is_empty() { + client + .send_command_typed::<_, Value>( + "Input.insertText", + &InsertTextParams { text: text_str }, + Some(session_id), + ) + .await?; + } else { + client + .send_command_typed::<_, Value>( + "Input.dispatchKeyEvent", + &DispatchKeyEventParams { + event_type: "keyDown".to_string(), + key: Some(key.clone()), + code: Some(code.clone()), + text: Some(text_str.clone()), + unmodified_text: Some(text_str.clone()), + windows_virtual_key_code: Some(key_code), + native_virtual_key_code: Some(key_code), + modifiers: None, + }, + Some(session_id), + ) + .await?; - client - .send_command_typed::<_, Value>( - "Input.dispatchKeyEvent", - &DispatchKeyEventParams { - event_type: "keyUp".to_string(), - key: Some(key), - code: Some(code), - text: None, - unmodified_text: None, - windows_virtual_key_code: Some(key_code), - native_virtual_key_code: Some(key_code), - modifiers: None, - }, - Some(session_id), - ) - .await?; + client + .send_command_typed::<_, Value>( + "Input.dispatchKeyEvent", + &DispatchKeyEventParams { + event_type: "keyUp".to_string(), + key: Some(key), + code: Some(code), + text: None, + unmodified_text: None, + windows_virtual_key_code: Some(key_code), + native_virtual_key_code: Some(key_code), + modifiers: None, + }, + Some(session_id), + ) + .await?; + } if delay > 0 { tokio::time::sleep(tokio::time::Duration::from_millis(delay)).await; @@ -753,19 +767,59 @@ fn char_to_key_info(ch: char) -> (String, String, i32) { ' ' => (" ".to_string(), "Space".to_string(), 32), _ => { let key = ch.to_string(); - let code = if ch.is_ascii_alphabetic() { - format!("Key{}", ch.to_uppercase()) + if ch.is_ascii_alphabetic() { + // For letters the Windows VK code equals the uppercase ASCII value. + let upper = ch.to_ascii_uppercase(); + let code = format!("Key{}", upper); + let key_code = upper as i32; + (key, code, key_code) } else if ch.is_ascii_digit() { - format!("Digit{}", ch) + let code = format!("Digit{}", ch); + let key_code = ch as i32; + (key, code, key_code) } else { - String::new() - }; - let key_code = ch as i32; - (key, code, key_code) + let (code, key_code) = punctuation_key_info(ch); + (key, code.to_string(), key_code) + } } } } +/// Return the DOM `KeyboardEvent.code` value and Windows virtual-key code for +/// a punctuation / symbol character assuming a US keyboard layout. +/// +/// The Windows virtual-key codes (VK_OEM_*) differ from ASCII values for +/// punctuation. Using the raw ASCII code would misidentify characters – e.g. +/// '.' (ASCII 46) collides with VK_DELETE (0x2E = 46), causing the period to +/// be swallowed. +fn punctuation_key_info(ch: char) -> (&'static str, i32) { + match ch { + // VK_OEM_1 (0xBA = 186) — ";:" key on US layout + ';' | ':' => ("Semicolon", 186), + // VK_OEM_PLUS (0xBB = 187) — "=+" key + '=' | '+' => ("Equal", 187), + // VK_OEM_COMMA (0xBC = 188) — ",<" key + ',' | '<' => ("Comma", 188), + // VK_OEM_MINUS (0xBD = 189) — "-_" key + '-' | '_' => ("Minus", 189), + // VK_OEM_PERIOD (0xBE = 190) — ".>" key + '.' | '>' => ("Period", 190), + // VK_OEM_2 (0xBF = 191) — "/?" key + '/' | '?' => ("Slash", 191), + // VK_OEM_3 (0xC0 = 192) — "`~" key + '`' | '~' => ("Backquote", 192), + // VK_OEM_4 (0xDB = 219) — "[{" key + '[' | '{' => ("BracketLeft", 219), + // VK_OEM_5 (0xDC = 220) — "\\|" key + '\\' | '|' => ("Backslash", 220), + // VK_OEM_6 (0xDD = 221) — "]}" key + ']' | '}' => ("BracketRight", 221), + // VK_OEM_7 (0xDE = 222) — "'\""" key + '\'' | '"' => ("Quote", 222), + _ => ("", 0), + } +} + fn named_key_info(key: &str) -> (String, String, i32) { match key.to_lowercase().as_str() { "enter" | "return" => ("Enter".to_string(), "Enter".to_string(), 13), @@ -792,3 +846,105 @@ fn named_key_info(key: &str) -> (String, String, i32) { } } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Verify that `char_to_key_info` returns the correct (key, code, + /// windowsVirtualKeyCode) triple for every character in Playwright's + /// USKeyboardLayout. The expected values below are taken verbatim from + /// playwright-core/lib/server/usKeyboardLayout.js so that any drift from + /// Playwright's behaviour is caught immediately. + #[test] + fn test_char_to_key_info_matches_playwright_layout() { + // (character, expected_code, expected_vk_code) + let cases: &[(char, &str, i32)] = &[ + // Letters – VK code must equal the uppercase ASCII value. + ('a', "KeyA", 65), + ('z', "KeyZ", 90), + ('A', "KeyA", 65), + // Digits + ('0', "Digit0", 48), + ('9', "Digit9", 57), + // Punctuation – these are the values from Playwright's layout. + // The bug that prompted this test sent '.' as VK 46 (= VK_DELETE). + ('.', "Period", 190), + (',', "Comma", 188), + ('/', "Slash", 191), + (';', "Semicolon", 186), + ('\'', "Quote", 222), + ('[', "BracketLeft", 219), + (']', "BracketRight", 221), + ('\\', "Backslash", 220), + ('`', "Backquote", 192), + ('-', "Minus", 189), + ('=', "Equal", 187), + // Shifted variants produced by the same physical keys. + ('>', "Period", 190), + ('<', "Comma", 188), + ('?', "Slash", 191), + (':', "Semicolon", 186), + ('"', "Quote", 222), + ('{', "BracketLeft", 219), + ('}', "BracketRight", 221), + ('|', "Backslash", 220), + ('~', "Backquote", 192), + ('_', "Minus", 189), + ('+', "Equal", 187), + // Whitespace / control + (' ', "Space", 32), + ('\n', "Enter", 13), + ('\t', "Tab", 9), + ]; + + for &(ch, expected_code, expected_vk) in cases { + let (key, code, vk) = char_to_key_info(ch); + assert_eq!( + code, expected_code, + "char {:?}: expected code {:?}, got {:?}", + ch, expected_code, code + ); + assert_eq!( + vk, expected_vk, + "char {:?}: expected VK {}, got {} (ASCII would be {})", + ch, expected_vk, vk, ch as i32 + ); + // key should be the character itself (except control chars). + if !ch.is_control() { + assert_eq!(key, ch.to_string(), "char {:?}: key mismatch", ch); + } + } + } + + /// Regression test: period must NEVER map to VK 46 (VK_DELETE). + #[test] + fn test_period_is_not_vk_delete() { + let (_, _, vk) = char_to_key_info('.'); + assert_ne!( + vk, 46, + "Period must not use VK code 46 (VK_DELETE); expected 190 (VK_OEM_PERIOD)" + ); + assert_eq!(vk, 190); + } + + /// Characters outside the US keyboard layout should return (key, "", 0) + /// so that `type_text` falls back to `Input.insertText`. + #[test] + fn test_unmapped_chars_return_zero_keycode() { + for ch in ['@', '#', '$', '%', '^', '&', '*', '(', ')', '€', '£', '你'] { + let (key, code, vk) = char_to_key_info(ch); + assert_eq!( + code, "", + "char {:?}: unmapped char should have empty code, got {:?}", + ch, code + ); + assert_eq!( + vk, 0, + "char {:?}: unmapped char should have VK 0, got {}", + ch, vk + ); + assert_eq!(key, ch.to_string()); + } + } +}