iT邦幫忙

2026 iThome 鐵人賽

DAY 13
0
Software Development

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

Day 13:在 CI 全綠,到中文環境全滅——實體按鍵與組字緩衝區

  • 分享至 

  • xImage
  •  

今天這一篇是一個「以為合成輸入壞了,結果是作業系統在認真工作」的故事。

這也是整個系列裡,最能說明「環境隱含假設」代價的一篇。


靈異現象:在 CI 上全綠,在 Windows 虛擬機上一字不發

故事的起點是一段為了追求「最真實輸入模擬」而寫的測試:

def test_physical_keys_deliver_text(dialog):
    edit = edit_of(dialog)
    assert edit.send_physical_keys("hello") is True
    assert value_of(edit, "hello") == "hello"

在 GitHub Actions 的 CI runner(標準的英文版 windows-latest)上,這段測試跑了幾十次,每次都是俐落的綠燈。

然而,當我在自己開的一台 Windows 虛擬機(繁體中文 zh-TW 桌面)上執行時,測試毫無預警地亮起紅燈:edit.get_value() 回傳了 空字串 ""

輸入欄位空空如也,一個字都沒有進去。

第一次推論:這條路不能走?

當你看到 assert edit.send_physical_keys("hello") is True 成功回傳、但後面的輸入框卻是空白時,工程師的第一個直覺通常是:

SendInput 的掃描碼模擬有 bug,或者某些控制項根本不吃掃描碼。這條路走不通。」

因為在 Day 11 我們提過:Unicode 注入(KEYEVENTF_UNICODE)是直接把字元碼位送交控制項,它快、穩定,而且不管在什麼環境下打 "hello",輸入框裡永遠都是 "hello"

如果換成 Unicode 注入就秒過,那顯然是「掃描碼這條路壞了」吧?

但這個結論經不起進一步的推敲。


單一變因實驗:一秒破案

與其猜測是 Windows 的輸入佇列掉了事件,不如把唯一可能影響按鍵意義的變因隔開。

同一個文字控制項、同一支測試程式、同一台電腦,我不動任何程式碼,只做了一件事: 切換作用中的鍵盤佈局(Keyboard Layout / HKL)

layout=0x04040404 (zh-TW 繁體中文注音)  → edit.get_value() == ""
layout=0x04090409 (en-US 美式英文)      → edit.get_value() == "hello"

把鍵盤佈局切到 en-US,測試立刻綠燈;切回 zh-TW 注音,欄位再次歸零。

答案一槍斃命: 不是合成輸入壞了,是注音輸入法把按鍵全部攔截進組字區(Composition)了!


注音鍵盤看見了什麼?

退一步想想注音鍵盤的真實運作方式:

在繁體中文標準注音鍵盤上,那些沒有加修飾鍵的英文字母, 根本不是英文字母

  • h 是「ㄘ」
  • e 是「ㄍ」
  • l 是「ㄠ」
  • o 是「ㄟ」

當真實使用者坐在電腦前,在中文模式下按下鍵盤上的 h-e-l-l-o 時,使用者 不是 要輸入英文單字 "hello",使用者是要拼寫注音符號!

這時 Windows 的輸入法架構(IME)會介入:

  1. 攔截物理按鍵;
  2. 啟動組字狀態機;
  3. 將這串按鍵送進組字緩衝區;
  4. 安靜地等待使用者按下聲調鍵(空白鍵是一聲,3、4、6、7 是其他聲調)或是選字確認鍵。

字元從來沒有到達過那個 EDIT 控制項——它們卡在輸入法的肚子裡。


這正是掃描碼存在的理由

想通這一點的瞬間,整個問題的因果徹底倒過來了。

回顧 Day 11 的三條輸入路徑:

輸入路徑 傳送內容 經過輸入法(IME)嗎?
虛擬鍵碼(VK) 抽象鍵概念(如 VK_RETURN 視按鍵而定(功能鍵通過,字母鍵可能觸發)
掃描碼(Scan Code) 物理鍵盤位置(send_physical_keys 一定會經過輸入法(IME Sees Everything)
Unicode 注入 字元碼位(KEYEVENTF_UNICODE 完全跳過(Bypasses IME)

我們當初為什麼要實作 send_physical_keys
正是為了要提供一條 最高保真度、完全比照真實人類物理敲擊 的輸入路徑。

既然是物理敲擊,它就必須承受真實世界中實體鍵盤會遭遇的一切:鍵盤佈局的轉譯、輸入法的攔截、組字視窗的扣押。

如果 send_physical_keys("hello") 在中文注音環境下,居然能神不知鬼不覺地直接把 "hello" 送進文字框,那才叫大 bug!那代表我們的合成輸入根本沒有騙過作業系統,它在底層抄了小路,繞過了真實世界的輸入管線。

換句話說: 底層沒有壞,注音輸入法正在忠實執行它的職責。壞的是我的測試斷言。


那個測試到底在斷言什麼?

assert edit.get_value() == "hello"

表面上它在斷言「掃描碼能把文字送進控制項」。
但實際上,它成立的前提是: 「執行這台測試的機器,沒有載入任何會攔截拉丁字母的輸入法。」

也就是說,這段測試真正斷言的是:

「跑這個測試的人,用的是純英文環境。」

這是一個非常糟糕、卻在開源界屢見不鮮的 隱含假設(Implicit Assumption)

GitHub Actions、Azure DevOps、AWS CodeBuild 這些雲端 CI runner,預設乾淨的 Windows 映像全部都是 en-US。在英文 CI 上,這段測試永遠綠燈通關;
但只要哪天一個台灣、日本(Romaji/Kana)或韓國的開源開發者把專案 clone 到自己的本機跑測試,整套測試套件就會全面陣亡——而且失敗訊息只會冷冷地噴出 AssertionError: assert '' == 'hello',完全不會給出任何提示。


人贓俱獲:Win32 訊息流與組字緩衝區

為了更清楚看見這道牆,我們看一下 Win32 訊息層的傳遞過程:

[ send_physical_keys("h") 送出掃描碼 ]
                  │
                  ▼
   WM_KEYDOWN (VK_H, ScanCode 0x23)
                  │
                  ▼
         TranslateMessage(&msg)
                  │
                  ├──► 輸入法(IME / TSF)判定為注音聲符「ㄘ」
                  │    攔截按鍵,不生成 WM_CHAR!
                  │    轉發 WM_IME_COMPOSITION 寫入組字緩衝區
                  │
                  ▼
         DispatchMessage(&msg)
                  │
                  ▼
           [ EDIT 控制項 ]
     (完全沒有收到 WM_CHAR,欄位維持空白)

普通的 Win32 文字方塊是收到 WM_CHAR 才把字元貼入編輯器的。輸入法把訊息扣住了,控制項當然一片空白。

我們可以呼叫 Win32 的 IMM32 API,直接把卡在組字緩衝區裡的字串抓出來看:

from wintegrate.interop import imm32, GCS_COMPSTR
import ctypes

def get_ime_composition_string(hwnd: int) -> str:
    himc = imm32.ImmGetContext(hwnd)
    if not himc:
        return ""
    try:
        size = imm32.ImmGetCompositionStringW(himc, GCS_COMPSTR, None, 0)
        if size <= 0:
            return ""
        buf = ctypes.create_unicode_buffer(size // 2 + 1)
        imm32.ImmGetCompositionStringW(himc, GCS_COMPSTR, buf, size)
        return buf.value
    finally:
        imm32.ImmReleaseContext(hwnd, himc)

在打完 "hello" 的瞬間執行這段函式:

  • edit.get_value()""
  • get_ime_composition_string(dialog.hwnd)"ㄘㄍㄠㄠㄟ"

字元沒有掉,五個注音符號整整齊齊地躺在輸入法的組字緩衝區裡!


真實專案的血淚:為什麼自動化測試不能不管輸入法?

這個現象在驅動真實開源應用程式(如 KeePassXC、Notepad++、PowerToys)時,會引發連鎖災難:

1. KeePassXC:Auto-Type 與主密碼直接解鎖失敗

在 KeePassXC 的自動化測試中(例如測試 PR #13648 / Issue #12956 的 0x200a 安全桌面憑證提示),腳本必須在密碼框填入主密碼。
如果當前環境處於中文注音模式,送出的密碼全部變成注音卡在緩衝區,後續送出 Enter 鍵時,密碼根本沒進去,直接觸發「密碼不正確」或系統警告音,整個測試直接掛死逾時。

2. Notepad++ 與 PowerToys:搜尋框幽靈空白

在 Notepad++ 尋找視窗、或是 PowerToys Run / Shortcut Guide 的搜尋欄中,自動化測試需要鍵入關鍵字來過濾清單。在中文環境下,搜尋欄完全空白,列表沒有任何項目被過濾,後續的所有定位器與斷言直接全死。

3. 錄影現場的偽證(結合鍵盤 HUD)

在沒有按鍵 HUD 的 CI 錄影裡,維護者看到的畫面是: 視窗開著,但文字框一直空白,像是在發呆
只有在我們為 wintegrate 0.5.5 打造了純記憶體合成的鍵盤 HUD(透過 WH_KEYBOARD_LL 鉤子直接在影像幀上打按鍵膠囊)之後,錄影才還原了現場:
影片底部清清楚楚跳出了 [ H ] [ E ] [ L ] [ L ] [ O ],但上方的文字框紋絲不動——這正是「按鍵真的有送出,是被輸入法攔走」無可辯駁的視覺證據。


模式要「建立」,不能「偵測」

那我能不能在測試一開頭先寫個 if is_ime_active():,如果是中文環境才處理?

不行。模式只能「建立」,不能「偵測」。

在實際探索 Win32 輸入機制時會發現:現代 Windows 控制項混雜了 TSF(Text Services Framework)與輸入佇列隔離機制。如果直接跨處理程序對目標視窗查詢輸入狀態,往往只能拿到不可靠的資訊或 NULL Context。

既然在跨處理程序的環境下無法穩定偵測當前狀態,唯一的活路就是 強制建立確定性
在送出實體按鍵前,無條件透過 Context Manager 將目標輸入環境推入已知的 英數轉換模式(Alphanumeric Mode) ,執行完畢後再精確復原。

就像真實人類在注音輸入法下打英文前,會按一下 Shift 一樣。


另一個潛伏的桌面全域狀態:Caps Lock

然而,在建立輸入法模式時,我們又撞上了另一個隱密的刺客: Caps Lock(大寫鎖定)

Caps Lock 在 Windows 中是 桌面全域鎖存狀態(Desktop-Global Latched State) 。它不屬於任何一個視窗或處理程序,而是整個桌面共用的。

如果跑測試前使用者的鍵盤剛好開著 Caps Lock,或者前一個崩潰的測試留下了 Caps Lock:

  1. 在英數模式下,你送出 send_physical_keys("hello"),實體按鍵受到大寫鎖存影響,輸入框收到的會是 "HELLO"!測試噴出 assert 'HELLO' == 'hello' 陣亡。
  2. 在某些輸入法模式下,Caps Lock 甚至會改變按鍵是進入組字還是輸出 ASCII。

因此,一個合格的測試框架在切換輸入法模式時,必須 同時對 Caps Lock 進行正規化與狀態復原


修法:Context Manager 控制變因,而不是放寬斷言

不要放寬斷言(例如改寫成 assert val in ["hello", ""],那是鴕鳥心態);也不要試圖把整台機器的鍵盤佈局(HKL)強切到 en-US(那是破壞性的,而且跨處理程序根本切不動,明天詳談)。

正確的做法是用 Context Manager 鎖定 轉換模式(Conversion Mode)Caps Lock

@contextmanager
def ime_mode(self, conversion: int):
    """Puts this window's IME into a known conversion mode for the block."""
    original = get_ime_conversion(self.hwnd)
    caps_was_on = get_toggle_key_state(VK_CAPITAL)

    # 1. 建立目標輸入法轉換模式(如 ALPHANUMERIC)
    self._set_ime_conversion_settled(int(conversion))

    # 2. 正規化 Caps Lock,避免把 hello 變成 HELLO
    if caps_was_on:
        set_caps_lock(False)

    try:
        yield self
    finally:
        # 3. 離開時精確復原
        if original is not None:
            self._set_ime_conversion_settled(int(original))
        if caps_was_on:
            set_caps_lock(True)

有了這個工具,測試就可以問出一個 乾淨且與環境無關的問題

@pytest.fixture
def latin_dialog(dialog):
    """The fixture dialog with the IME in alphanumeric mode."""
    with dialog.ime_mode(ImeConversion.ALPHANUMERIC):
        yield dialog

def test_physical_keys_deliver_text(latin_dialog):
    edit = edit_of(latin_dialog)
    assert edit.send_physical_keys("hello") is True
    assert value_of(edit, "hello") == "hello"

這段測試在英文的 GitHub Actions CI 上全綠,在中文的 Windows 桌面開發機上也全綠。我們不再把「環境碰巧是英文」當成測試通過的隱含前提。


測試哲學:成對對稱斷言(Symmetric Dual Assertions)

最後,既然知道注音在原生中文模式下必定會攔截按鍵,我們何不把它當成一個 feature 寫進測試套件?

如果我們這樣寫:

# 脆弱的單向測試
def test_native_mode_intercepts_unshifted_scan_codes(dialog):
    edit = edit_of(dialog)
    with dialog.ime_mode(ImeConversion.NATIVE):
        edit.send_physical_keys("hello")
        assert edit.get_value() == ""  # 被吃掉了

這個測試 毫無證明力

因為「輸入框是空的」有太多種可能的原因:

  • 視窗根本沒拿到焦點;
  • 控制項被停用(Disabled);
  • UIPI 權限隔離擋掉了 SendInput
  • 按鍵根本沒送達。

在任何一種災難性的 bug 發生時,這個測試都會通過。 一個只驗證失敗方向的測試,證明不了失敗的原因。

要讓它具備嚴謹的科學證明力,必須採用 成對對稱斷言

@requires_ime
def test_native_mode_intercepts_unshifted_scan_codes(dialog):
    """Asserts both directions: scan codes reach the IME, not the ether."""
    edit = edit_of(dialog)

    def type_hello() -> str:
        edit.send_keys("{HOME}+{END}{DELETE}")
        value_of(edit, "")
        edit.send_physical_keys("hello")
        return value_in(edit, ("hello", ""))

    # 1. 中文原生模式:必須攔截,欄位必須為空
    with dialog.ime_mode(ImeConversion.NATIVE):
        swallowed = type_hello()

    # 2. 英數模式:同一批掃描碼必須通行無阻,欄位必須得到 hello
    with dialog.ime_mode(ImeConversion.ALPHANUMERIC):
        delivered = type_hello()

    assert delivered == "hello", "英數模式必須讓掃描碼暢通無阻到達控制項"
    assert swallowed == "", "中文原生模式必須將相同的按鍵吃進組字狀態機"

只有當第二個斷言 delivered == "hello" 成立,排除了「這台機器根本打不出字」的所有替代假設之後,第一個斷言 swallowed == "" 才真正具有證明力。

成對的斷言,才能鎖定因果。


小結

  • send_physical_keys 在中文注音下打不出英文字母—— 這是正確行為 ,掃描碼就是要忠實反映輸入法管線。
  • 「測試在本地或虛擬機失敗」有時不是程式寫壞了,是測試踩了「假設執行環境是純英文」的隱含前提。
  • 注音沒有漏掉字元:按鍵在 TranslateMessage 被轉成 WM_IME_COMPOSITION,扣押在 GCS_COMPSTR 組字緩衝區中。
  • 輸入法模式要 「建立」而不是「偵測」 ,因為 TSF 介面下的視窗對 IMM32 的查詢常常無聲回傳 0。
  • Caps Lock 是桌面全域鎖存狀態,切換模式時必須一併正規化。
  • 驗證攔截行為必須採用 成對對稱斷言 :只驗空字串,證明不了失敗的原因。

明天講一個我一度以為是解法、後來變成教科書級反例的東西: 為什麼切換鍵盤佈局(HKL)是一則「請求」而不是「指令」?以及六種試圖跨處理程序強制切換佈局的做法,為什麼全部慘烈失敗。


上一篇
Day 12:從 ctypes.windll 到私有 Handle:避免全域污染的 Win32 API 呼叫守則
下一篇
Day 14:為什麼切換鍵盤佈局永遠失敗?——跨處理程序的輸入法控制
系列文
Windows 桌面軟體 CI 實戰:從系統工具開發到 Zero-RDP 自動化測試錄影24
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言