iT邦幫忙

2026 iThome 鐵人賽

DAY 11
0
Software Development

Windows 桌面軟體 CI 實戰:從系統工具開發到 Zero-RDP 自動化測試錄影系列 第 11

Day 11:「按一個鍵」有三種做法,而它們送到不同的地方

  • 分享至 

  • xImage
  •  

第三章開始。今天的問題聽起來很蠢:在 Windows 上模擬一次按鍵,有幾種方法?

答案是至少三種,而選錯的話,你的測試會在中文環境下整組失效。


三條路

SendInput 這個 API 是所有合成輸入的入口,但它可以送出三種不同性質的東西。

路線一:虛擬鍵碼(Virtual Key)

send_vk_input(VK_RETURN)

送出「Enter 這個鍵」這個抽象概念。系統知道 VK_RETURN 是什麼意思,不需要知道你的鍵盤長什麼樣。

適合功能鍵:Enter、Tab、Esc、方向鍵、F1。這些鍵在所有鍵盤佈局上意義相同。

路線二:掃描碼(Scan Code)

send_physical_keys("hello")

送出「鍵盤上第 X 個位置的鍵被按下了」這個物理事實。系統接著查目前的鍵盤佈局,把位置翻譯成字元。

這是最接近真實使用者的一條路。因為它是物理事件,所以:

  • 輸入法看得到它
  • 遊戲和 DirectInput 應用看得到它
  • 任何掛在低階鍵盤鉤子上的東西都看得到它

路線三:Unicode 注入

for ch in "你好":
    send_char_input(ch)

KEYEVENTF_UNICODE 這個旗標讓你直接送出一個字元的碼位,完全跳過鍵盤佈局。系統不去查任何按鍵位置,直接把這個字元交給有焦點的控制項。一次一個碼位,所以 send_char_input 吃的是單一字元。

底層細節:VK_PACKET (0xE7) 與被誤解的 scanCode

當你使用 KEYEVENTF_UNICODE 注入字元時,Windows 底層的 KEYBDINPUT 結構會把虛擬鍵碼固定設為 VK_PACKET = 0xE7,並將真正的 16 位元 Unicode 字元塞進 wScan(即掃描碼欄位)。

這導致了一個有趣的第三方工具 bug:許多在低階鍵盤鉤子(WH_KEYBOARD_LL)上監聽按鍵的螢幕視覺化工具,如果沒有特別處理 VK_PACKET,會盲目將 wScan 當作實體鍵盤的虛擬鍵碼來解讀——

  • 輸入 'q'(Unicode 113 / 0x71 = VK_F2)被解讀成 F2
  • 輸入 'a'(Unicode 97 / 0x61 = VK_NUMPAD1)被解讀成 1

好處是它能打出任何字元,不管你的佈局上有沒有那個鍵。壞處是——


「跳過佈局」也意味著跳過輸入法

這是三條路裡最重要的一個差別,wintegrate 把它列在文件的陷阱頁上:

## `KEYEVENTF_UNICODE` bypasses the IME

Unicode injection hands the codepoint straight to the control. An IME never sees
it, so composition never starts and no candidate window appears. Testing IME
behaviour requires scan-code input (`send_physical_keys`).

想想這代表什麼。

如果你要測「使用者用注音打字」這件事,用 Unicode 注入是不可能測到的。你可以把「你好」兩個字塞進欄位裡,欄位裡確實會出現「你好」——但組字過程從來沒有發生過。沒有候選字視窗、沒有組字字串、輸入法的狀態機從頭到尾沒有被觸發。

你測到的是「這個欄位能顯示中文」,不是「注音輸入法能用」。

反過來說,如果你只是要在一個欄位裡放進某段文字好繼續測後面的流程,Unicode 注入才是對的選擇——它快、可靠、不受環境影響。

沒有哪一條路「比較好」。有的是你想測的到底是哪一層。

你要測的 該用 wintegrate API
輸入法組字、候選字、中英切換 掃描碼 send_physical_keys(...)
功能鍵、快捷鍵 虛擬鍵碼 send_vk_input(...) / send_hotkey(...)
只是要把文字放進欄位 Unicode 注入 send_char_input(...) / send_keys(...)

SendInput 會部分失敗,而且不拋例外

三條路共用的一個坑:SendInput 的回傳值是它成功送出了幾個事件

n = user32.SendInput(count, inputs, ctypes.sizeof(INPUT))

如果 n < count,代表有事件被丟掉了:另一個執行緒用 BlockInput 擋住了輸入,或是佇列滿了。

但有一種丟法它不會告訴你。 UIPI(使用者介面權限隔離)擋下往更高完整性等級視窗送的輸入時,微軟文件明寫回傳值與 GetLastError 都不會反映這件事——SendInput 回報全數送出,欄位是空的。Day 7 那個對著 0x200a 憑證提示的案例量到的就是這個。所以回傳值要檢查,但它抓不到 UIPI;那一種只能靠比較兩邊的完整性等級,或靠後置條件。

多數程式碼連回傳值都不檢查。於是你送了五個按鍵、系統收下零個,而你的程式繼續往下跑,一路到某個斷言失敗為止。

def _send_input_checked(arr, label: str) -> bool:
    sent = user32.SendInput(len(arr), arr, ctypes.sizeof(INPUT))
    if sent != len(arr):
        err = ctypes.get_last_error()
        logger.warning(f"SendInput for {label} sent {sent}/{len(arr)} events (error {err})")
        return False
    return True

又是同一個主題:檢查回傳值,不要假設成功。這在整個系列裡已經出現太多次了。


按鍵語法:一個可以被單元測試的決定

send_keys("{HOME}+{END}{DELETE}") 這種字串語法需要一個解析器。而解析器是這整個函式庫裡少數不需要 Windows 就能測的東西。

所以它被刻意拆成一個純函式:

# 註:此處 KeyAction 抽象表示 (kind, value, modifiers) 的動作 tuple
def parse_key_spec(spec: str) -> list[KeyAction]:
    """Parses a key specification string into actions. Pure — no Win32 calls."""

拆出來之後就可以測邊界條件了。例如重複次數:

send_keys("{DOWN 5}")          # 按五次向下
send_keys("{DOWN 1000000}")    # ← 這個要擋掉

一百萬次按鍵會讓測試看起來像當機。所以有上限:

MAX_KEY_REPEAT = 1000

而且重複次數只接受純數字{DOWN -5}{DOWN +3}{DOWN 1_000_000} 全部拒絕——Python 的 int() 很寬容,它接受底線分隔和正負號,如果直接拿來解析使用者輸入,1_000_000 會變成一百萬。

這種「語言本身的寬容變成你的漏洞」的狀況,我們在 Day 15 講 property-based testing 時會再看到。


小結

  • SendInput 有三種送法:虛擬鍵碼(抽象的鍵)、掃描碼(物理事件)、Unicode(直接送字元)
  • 只有掃描碼會經過輸入法——這決定了你能不能測中文輸入
  • Unicode 注入不是「比較差」,它是不同層級的工具
  • SendInput 會部分失敗且不拋例外,回傳值一定要檢查
  • 按鍵語法解析器該是純函式,這樣才測得動邊界條件

明天先岔開一天,講一個跟輸入有關、但影響範圍遠超過輸入的東西:ctypes.windll整個處理程序共享的全域狀態,而在它上面設定型別會波及每一個用 ctypes 的套件。


上一篇
Day 10:Control Pattern——問一個元素「你能做什麼」
下一篇
Day 12:從 ctypes.windll 到私有 Handle:避免全域污染的 Win32 API 呼叫守則
系列文
Windows 桌面軟體 CI 實戰:從系統工具開發到 Zero-RDP 自動化測試錄影24
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言