前面三天都在講怎麼產出證據:錄影、截圖、視窗普查。
這一天講一件更基本的事——什麼時候該停止推理,去看證據。
因為我很不擅長這件事。這個系列裡我有一連串錯誤結論,
而它們幾乎都有同一個形狀:一個合理的解釋,解釋得通所有已知症狀,而它是錯的。
CI 上四個測試同時紅了,訊息是這樣:
AssertionError: Ctrl+T did not open a tab (1 -> 1)
AssertionError: the new-tab button did not open a tab (1 -> 1)
驅動的是 Files(WinUI 3 的檔案總管)。而在同一次執行裡:
所以應用程式活著、UIA 樹完整、有些操作有效。只有「開新分頁」不動——
連 click() 都沒作用。
我當下想到的解釋有三個,都很合理:焦點不在內容區、按鈕的座標不對、
WinUI 的 XAML 命令還沒綁好。三個都可以寫程式去驗證,每一個都要一輪 CI。
我做的是另一件事:去看錄影。
CI 每一次執行都會把整場測試錄成一支影片(Day 3)。
從 log 的時間戳算出偏移量,抽出那一格:
Files is running as administrator
Due to a limitation with Windows and WinAppSdk, drag and drop isn't available
when running Files as admin. …
[ OK ][ Don't show again ]
一個 modal 對話框,蓋在它自己的視窗上。
CI runner 是提升權限執行的——每一台都是。所以 Files 每一次啟動都會彈這個,
而在我自己的 VM 上不會。
而它完全不影響 UIA。 分頁列就在對話框後面,讀得到、數得出來、automation_id 一應俱全。壞掉的只有一件事:鍵盤與滑鼠事件全部進了 modal。
我那三個「合理的解釋」全都錯,而且它們錯的方式很有教育意義——
它們都在解釋「為什麼這個操作失敗」,而真正的問題是「有另一個東西在接收輸入」。
第一層是關掉它。 Files 把這個提示記在設定檔裡:
DETERMINISTIC_STARTUP = {
"ContinueLastSessionOnStartUp": False,
"RestoreTabsOnStartup": False,
"OpenSpecificPageOnStartup": False,
"ShowRunningAsAdminPrompt": False, # ← 這一個
"ShowDataStreamsAreHiddenPrompt": False, # ← 以及這一個
}
首次執行的提示要事先關掉,這跟第一章講的「起始狀態要自己造」是同一件事。
第二層是我自己的 bug,而且更值得記。 那段設定的程式碼原本是這樣:
if settings_file.exists():
settings = json.loads(settings_file.read_text())
settings.update(DETERMINISTIC_STARTUP)
settings_file.write_text(json.dumps(settings))
「只在檔案存在時才修改」看起來很安全、很有禮貌。
而全新安裝的機器上那個檔案不存在——也就是說,
在最需要這段保護的環境裡,它整段被跳過了。
一個「只在安全的時候才動作」的防護,往往在唯一需要它的情況下不動作。
那次之後我把「加上量測」當成第一步而不是最後一步。同一輪工作裡還有兩次:
一次是「這個 app 的 UIA 樹是空的」。 一個 fixture 回報找不到任何分頁。
我沒有猜,而是讓失敗訊息自己 dump 整棵樹:
UIA descendants: 201
control types: button=41, list item=33, ..., tab item=11, ...
automation ids (71 total): [...]
tab item=11。樹一點都不空。這份 census 花了十行程式碼,
省掉的是一整條「Qt 的 accessibility 沒初始化」的錯誤路線。
一次是「印出來的東西不夠」。 上面那份 census 我只印了 automation_id 的尾段,
因為在我本機那是有意義的部分。改成印完整的 id 之後,答案立刻出現——
那些 id 是空字串。
一個診斷輸出的解析度,決定了它能推翻哪些假設。
上面那三次都是「一個假設被一份輸出推翻」。還有一種更花時間的形狀:
症狀太多,以至於你不會想到它們只有一個原因。
在 ARM64 的 CI 上,三個不同的應用程式同時壞掉,方式完全不同:
length=0、get_value()=='',focus_content_island() 回 False。三個應用程式、三種技術、三種症狀。我的第一個假設是「這些是 x64 建置在 ARM 上
跑模擬造成的」——聽起來完全合理,而且其中一個(WinMerge)換成原生建置真的修好了。
Notepad++ 換了之後還是一樣紅。 而去讀 PE 標頭發現,
我自己虛擬機上那份測試全過的 Notepad++ 本來就是 ARM64 原生。
真正的原因是一句加進失敗訊息的話:
Notepad++ never became the foreground window;
<hwnd=0x10206 class='Windows.UI.Core.CoreWindow' title='Microsoft account'>
has it instead, so every keystroke below would have gone there
一個系統提示視窗從開機就握著前景。它同時解釋了三個症狀:
| 症狀 | 為什麼 |
|---|---|
| 打不進字 | 按鍵全部送到那個視窗 |
| 按鈕點擊無效 | 合成點擊被前景視窗攔走 |
SetFocus 被拒絕 |
另一個視窗握著前景時,OS 拒絕對背景視窗的元素設焦點 |
第三列最值得記,因為 SetFocus 不是輸入操作——我原本完全不會把它和
「有東西擋在前面」連起來。
而讓這句話出現的程式碼,是把一個原本寫成 verify=False 的呼叫改成驗證:
with win.foreground(verify=False): # 之前
這個 suite 到處都這樣寫,因為前景請求會跟桌面上其他事情搶,
驗證它有時候會沒必要地失敗。而代價是:當它真的沒拿到前景時,
那件事完全不會被說出來。
每一個你關掉的驗證,都是一個你選擇不知道的事實。
那個選擇有時候是對的——但它要是刻意的,而不是預設的。
量測也會騙人,而且騙得比沒有量測更徹底。
我寫了一個函式,要把鍵盤焦點送進 WinUI 3 的 XAML content island。
它的驗證是「走訪焦點元素的祖先鏈,看有沒有走到 content island」:
for _ in range(12):
if node.handle:
return node.handle == hwnd # 第一個有 handle 的祖先
node = node.get_parent()
實測的祖先鏈是這樣:
Button(無 handle) → TabView(無 handle) → InputSiteWindowClass(有 handle!)
→ DesktopChildSiteBridge → WinUIDesktopWin32WindowClass
InputSiteWindowClass 有自己的 handle,而且在 content island 下面。
所以我的迴圈在它那裡就回傳了——對一個明明在裡面的焦點回答「不在裡面」。
一個剛剛成功的呼叫回報 False,而且第二次呼叫會花掉整個 timeout 重做已經做完的事。
但真正危險的是另一個方向。 如果我當初把那個檢查寫成
「焦點離開頂層視窗了嗎」,它會回報成功——而快速鍵照樣被吞掉。
實測有一條路正是這樣:對「第一個可聚焦的後代」設焦點,
焦點確實離開了頂層視窗,落在標題列的 input sink 上,而 Ctrl+T 完全沒作用。
| 送鍵前做的事 | 焦點跑到哪 | 結果 |
|---|---|---|
| 什麼都不做 | 頂層視窗本身 | 沒作用 |
對頂層視窗 SetFocus() |
沒變 | 沒作用 |
送一個 Tab |
沒變 | 沒作用 |
| 第一個可聚焦的後代 | 標題列的 input sink | 沒作用(但焦點確實動了) |
對 content island SetFocus() |
TabView 裡的按鈕 |
成功 |
第四列是這張表裡最重要的一列:它是一個會通過天真檢查的失敗。
SetFocus 被拒絕」verify=False),都是一個你選擇不知道的事實第二章開始進入 UI Automation 本身,而第一件事是:它不是一個 API,是 COM。