今天講一個我寫錯過的測試——而它教我的事情,比它修好之後的樣子更有價值。
Win32 的傳統 EDIT 控制項有個經典行為:用鍵盤 Tab 導覽到它、或透過對話框管理器(Dialog Manager)設定焦點時,它會把內容全選。
這背後的 Win32 機制是:當對話框處理焦點切換時,會向目標控制項發送 WM_GETDLGCODE。標準的 EDIT 會回傳包含 DLGC_HASSETSEL 的旗標,對話框管理器收到後,便會主動發送 EM_SETSEL, 0, -1 將文字全選。這是 Windows 從 Windows 3.1 時代延續至今的慣例,目的是讓使用者 Tab 進輸入框後直接打字就能覆蓋原本的預設值。
於是我寫了這個測試:
def test_focusing_an_edit_selects_its_contents(dialog):
edit = edit_of(dialog)
edit.set_value("ab")
edit.set_focus()
edit.send_keys("{DELETE}")
assert edit.get_value() == "" # 全選後刪除 → 期待是空的
邏輯看似無懈可擊:文字是 "ab",設定焦點會全選,送出 Delete 鍵後應該清空。
在 x64 CI 機器上,綠燈通過。
換到 ARM64 機器上,測試爆了:拿到的是 "ab"。
問題不在 ARM64 CPU 本身,而在 set_focus() 的實作機制。
在桌面自動化函式庫裡,為了對抗各種實作殘缺的第三方應用,set_focus() 通常不會只做一件事,而是做兩件事:
IUIAutomationElement::SetFocus()
SendInput 合成一次滑鼠點擊為什麼要兩件都做?因為現實很殘酷:有些自訂控制項的 UIA Provider 沒有正確實作 SetFocus(呼叫了毫無反應);有些傳統控制項則對合成的點擊事件反應不良。雙管齊下,是獲取焦點成功率最高的做法。
但這兩個操作對「選取狀態」的影響恰好相反:
| 到達方式 | 走訪途徑 | 對選取狀態的影響 |
|---|---|---|
UIA SetFocus() |
COM RPC → Provider → SetFocus(hwnd) |
觸發預設焦點邏輯,全選文字 (EM_SETSEL 0, -1) |
| 合成滑鼠點擊 | SendInput → 系統輸入佇列 → WM_LBUTTONDOWN |
取消選取,游標落在點擊座標 (EM_SETSEL pos, pos) |
現在看出來了:兩個操作都送出去了,一個命令全選,另一個命令取消全選。最後生效的是哪一個?
取決於它們在 Windows 訊息佇列裡被處理的時序。
UIA 的 SetFocus() 走的是同步的 COM 跨處理程序呼叫,而 SendInput 產生的硬體滑鼠訊息會被丟進執行緒的輸入訊息佇列(Message Queue)。在高效能的本機 x64 上,COM 呼叫與後續按鍵可能搶先在滑鼠訊息派發前抵達;但在虛擬化環境或不同排程特性的 ARM64 runner 上,滑鼠點擊的 WM_LBUTTONDOWN 在 {DELETE} 到達前被派發了,瞬間清除了選取狀態,Delete 鍵只刪除了一個不存在的選區。
「焦點之後的選取狀態」在架構上本來就是未定義的。
我的測試根本不是在驗證EDIT的功能,而是在測「這台機器上 COM 與輸入佇列誰跑得比較快」。
發現兩邊跑出不同結果,第一直覺往往是把斷言放寬:
# 致命的妥協
assert edit.get_value() in ("", "ab")
這行扣上去,兩台機器的 CI 都綠了。但這叫自欺欺人——一個接受所有可能結果的斷言,等於沒有斷言。 如果哪天文字框徹底壞掉、完全不收輸入,這個測試依然會微笑通過。
正確的工程處理必須分成兩步:
不可控的底層時序行為,必須明確標記在 API 文件上,昭告呼叫端不要對焦點後的選取做假設:
## The selection state after focus is undefined
Focusing a Win32 `EDIT` through UIA selects its entire contents, so the next
keystroke *replaces* the field. Arriving by click instead places the caret and
selects nothing. `set_focus` does both — UIA `SetFocus`, then a click — and which
one wins is timing-dependent: the same test produced an empty field on x64 and
`ab` on ARM64.
自動化測試不應該繼承隨機的環境狀態,而是要親手建立前置條件:
# 明確建立你要的選取範圍,不要賭運氣
edit.set_focus()
edit.send_keys("{HOME}+{END}{DELETE}")
{HOME}:將游標強制移到開頭,順便清除任何殘留的選取。+{END}:即 Shift+End,明確將整行反白選取。{DELETE}:乾淨俐落刪除選區。三個鍵,徹底抹平了 x64、ARM64、實體機與 VM 之間的所有時序差異。
Ctrl+A 在 Win32 EDIT 裡根本沒有作用在看到上面的 {HOME}+{END} 時,有人可能會問:「為什麼不用更直覺的 Ctrl+A?」
因為 Win32 標準 EDIT 控制項根本不認識 Ctrl+A。
Ctrl+A 是 RichEdit(RICHEDIT50W)或現代框架才內建的快捷鍵。傳統的 EDIT 控制項只認得 Ctrl+C、Ctrl+V、Ctrl+X 和 Ctrl+Z。如果一個 Win32 程式的編輯框能用 Ctrl+A,那是開發者在視窗訊息迴圈裡自己攔截 WM_CHAR / WM_KEYDOWN 補寫的。
而在測試裡盲目用 Ctrl+A,會引發另一種更可怕的「假綠燈」:
edit.send_keys("^a") # 傳統 EDIT 裡完全被忽略,什麼都沒發生
edit.send_keys("hello") # 字串直接附加在原本文字後面
assert "hello" in edit.get_value() # 通過!但那是因為字串出現在裡面,不是因為全選替換成功
如果斷言寫成 == "hello",測試會死;但只要有人手滑寫成 in,這個測試就會以錯誤的理由永遠通過,直到某一天正式環境爆出未清空的髒資料。
上面討論的是「焦點進去了,但內容選取未定義」。
但在現代 Windows 應用上,還有一個更陰險的陷阱:前景視窗判定成功、所有屬性檢查全部通過,但焦點根本被擋在內容大門之外。
Windows App SDK / WinUI 3 應用(例如知名開源檔案管理器 Files)徹底拋棄了 UWP 的 CoreWindow,回歸 Win32 架構。它的架構是外層一個標準 Win32 頂層視窗(類別名 WinUIDesktopWin32WindowClass),而內部的 XAML 視覺樹則宿主在名為 Content Island 的子視窗結構中(Microsoft.UI.Content.DesktopChildSiteBridge)。
當 Files 剛啟動完成時,系統現場會出現這樣詭異的狀態:
[Window Inspection]
Foreground Window: 0x1f101a6 (Title: '首頁 - Files', Class: 'WinUIDesktopWin32WindowClass')
UIA FocusedElement: <Window '首頁 - Files' (Type: 50032, Class: 'WinUIDesktopWin32WindowClass')>
GetForegroundWindow() 回傳的是它。Ctrl+T(開啟新分頁),完全沒有任何反應。因為 WinUI 3 的鍵盤加速鍵(Keyboard Accelerators)並不是由外層 Win32 視窗程序的 TranslateAccelerator 處理的,而是由 XAML 內部的 InputManager 與 KeyboardNavigation 子系統負責。
如果鍵盤焦點停留在外層的頂層 HWND 上,按鍵訊息(WM_KEYDOWN)會直接派發給外層的 DefWindowProc,它根本不知道 XAML 定義了什麼快速鍵,按鍵就此石沉大海。按鍵要生效,焦點必須穿過 HWND 邊界,進到 Content Island 內部的 InputSink。
我們在實機上對 Files 做了五組對照實驗:
| 送出按鍵前嘗試的處置 | 焦點最終落點 | 按下 Ctrl+T 的分頁變化 |
結果判定 |
|---|---|---|---|
| 1. 什麼都不做 | 頂層 HWND (WinUIDesktopWin32WindowClass) |
1 → 1 | ❌ 快速鍵被吞 |
2. 對頂層視窗 UIA 元素 SetFocus() |
頂層 HWND(毫無變化) | 1 → 1 | ❌ 快速鍵被吞 |
3. 送出一個 Tab |
頂層 HWND(無法推進) | 1 → 1 | ❌ 快速鍵被吞 |
| 4. 尋找並聚焦「第一個可聚焦後代」 | 標題列 input sink (InputNonClientPointerSource) |
1 → 1 | ⚠️ 假性成功陷阱 |
5. 鎖定 Content Island 子視窗 SetFocus() |
內部 TabView 的分頁按鈕 |
1 → 2 | ✅ 新分頁成功打開 |
特別看第 4 列:這是一個會通過天真檢查的災難。
很多自動化工具會寫:「如果焦點在視窗自己身上,就找第一個可聚焦的子元素聚焦。」結果它聚焦到了標題列非工作區的指標接收槽(InputNonClientPointerSource)。
這時去檢查「焦點離開頂層視窗了嗎?」,答案是「離開了(True)」!檢查綠燈,但使用者的 Ctrl+T 依然被吞掉。
InputSiteWindowClass為了徹底解決這個問題,wintegrate 打造了專門的 Window.focus_content_island() 方法。它的核心目標是:在不觸發任何滑鼠點擊(避免破壞選取或誤觸按鈕)的前提下,將鍵盤焦點引導進入 XAML Island。
但在寫驗證邏輯 _focus_is_inside(bridge_hwnd) 時,我們踩進了另一個 WinUI 3 的深水區。
直覺的判定邏輯是:「取得當前焦點元素,沿著父節點往上爬,如果第一個擁有視窗控制代碼(NativeWindowHandle)的祖先等於 bridge_hwnd,就代表焦點在 Island 裡面。」
實測抓出來的 UIA 祖先鏈卻是這樣:
Button (無 handle)
└─ TabView (無 handle)
└─ InputSiteWindowClass (有自己的 HWND!) <--- 陷阱在這
└─ DesktopChildSiteBridge (Island 根視窗,bridge_hwnd)
└─ WinUIDesktopWin32WindowClass (頂層視窗)
看到那個 InputSiteWindowClass 了嗎?
它是 Windows App SDK 在 Island 內部專門用來轉接 Win32 訊息與 XAML 輸入事件的內部視窗。它有自己的獨立 HWND,而且它位在 DesktopChildSiteBridge 的下方!
如果你的演算法寫成「找到第一個有 handle 的祖先就比對」,比對到的會是 InputSiteWindowClass 的 handle,它不等於 DesktopChildSiteBridge,演算法立刻判定 False。
結果就是:焦點明明已經完美進入 Content Island,你的驗證函式卻回報失敗。
在 wintegrate 中,最終穩健的實作長這樣:
# wintegrate/src/wintegrate/window.py
CONTENT_ISLAND_CLASS_SUBSTRINGS = (
"DesktopChildSiteBridge", # WinUI 3 / Windows App SDK
"Windows.UI.Composition", # 現代系統合成視窗
)
def focus_content_island(self, timeout: float = 3.0) -> bool:
"""將焦點安全移入 WinUI 3 Content Island,不依賴滑鼠點擊。"""
bridges = find_child_windows(self.hwnd, CONTENT_ISLAND_CLASS_SUBSTRINGS)
if not bridges:
return False
deadline = time.monotonic() + timeout
while True:
for bridge in bridges:
if self._focus_is_inside(bridge):
return True
try:
# 純 COM UIA SetFocus,嚴格關閉 click,不觸發多餘選取行為
UiaElement.from_handle(bridge).set_focus(verify=False, click=False)
except Exception as exc:
logger.debug(f"focus_content_island: SetFocus raised: {exc}")
if time.monotonic() >= deadline:
return False
time.sleep(0.05)
def _focus_is_inside(self, hwnd: int) -> bool:
"""檢查目前焦點元素是否屬於該 Island。
必須爬完整條祖先鏈,不能停在第一個有 handle 的節點,
因為 InputSiteWindowClass 有自己的 handle 且位於 bridge 之下。
"""
try:
node = UiaElement.get_focused()
except Exception:
return False
for _ in range(12): # 限制深度上限防禦無限迴圈
if node is None:
return False
try:
if node.handle == hwnd:
return True
node = node.parent
except Exception:
return False
return False
從 Day 6 到今天,我們探索了 Windows 視窗與 UI 自動化底層一連串「安靜失敗」的真實樣貌:
| 天數 | 表象症狀 | 底層架構真相 | 防禦性工程對策 |
|---|---|---|---|
| Day 6 | WindowCensus 視窗普查 |
視窗不是單一平面,有 Window Station、Desktop 與 Cloaked 狀態 | 建立原子快照,不假設即時 EnumWindows 的恆常性 |
| Day 7 | UIA COM 代理失效 | COM Runtime 跨處理程序透明封裝,底層程序重啟導致指標成懸空參照 | 拋棄長期快取物件,隨用隨解(re_resolve_element) |
| Day 8 | FindFirst 找不到跨 HWND 元素 |
跨子 HWND / XAML Island 邊界時,Provider 銜接破裂 | Fallback 到 RawViewWalker 手動走訪,設定嚴格逾時防禦 |
| Day 9 | 焦點與選取狀態隨機飄移 | 輸入佇列與 COM 時序競爭;Content Island 隔離了頂層加速鍵 | 永遠親手建立確定性(如 {HOME}+{END}),專門導引 Content Island 焦點 |
在 Windows 桌面應用世界裡,最昂貴的 bug 從來不是 Exception,而是那些回傳碼是 0、GetForegroundWindow() 回傳對的 HWND、但事情根本沒發生的安靜陷阱。
set_focus 的雙面刃:同時調用 UIA SetFocus 與滑鼠點擊,會在 Win32 佇列中引發「全選 vs 游標重設」的時序競爭,在不同 CPU 架構(x64 vs ARM64)上會暴露不同結果。in ("", "ab") 這種接受所有狀態的虛假斷言掩蓋時序問題。{HOME}+{END} 明確建立邊界,不依賴平台給予的初始焦點狀態。Ctrl+A 的偽成功:Win32 標準 EDIT 不支援 Ctrl+A,測試若用 in 驗證會引發極危險的假綠燈。DesktopChildSiteBridge 內部的 InputSink。只看前景視窗不代表鍵盤能作用,必須穿透 InputSiteWindowClass 才能正確引導並驗證焦點。明天第二章最後一篇:我們將探討如何用 Control Pattern 向元素提問「你到底具備什麼能力」,以及為什麼「不支援 GridPattern」這個錯誤訊息背後,隱藏著一套被多數人忽略的介面契約。