昨天的問題是「找不到」,至少它會誠實地失敗。今天這個更陰險:它找到了,回傳了,測試繼續跑,然後在別的地方以看不懂的方式壞掉。
我在一台沒裝 .NET 10 Desktop Runtime 的 ARM64 機器上啟動 Files(WinUI 3)。launch_and_discover 在 1248 ms 後回傳:
<Window hwnd=0x15104e6 pid=1288 class='#32770' title='Files.exe'>
#32770 是標準 Win32 對話框類別。讀它的子控制項:
SysLink: 'Architecture: arm64\nApp host version: 10.0.11\n\nLearn more:...'
Button: 'Download it now'
Button: '取消(&C)'
這是 .NET app host 在找不到執行階段時彈的訊息框。而我給探索的條件是 process_names=("Files.exe",)——它完美命中。那個對話框真的屬於 Files.exe 這個處理程序,探索沒有壞,是「處理程序名稱吻合」從來就不等於「這是應用程式主視窗」。
接下來每一個元素查詢都失敗,對著一個看起來一切正常的視窗。
回傳的是一個合法的 Window 物件:有 hwnd、有 pid、exists() 是 True。沒有例外,沒有警告。錯誤出現在下一步,訊息是「找不到某個元素」。你會去查那個元素的 automation id 對不對、會不會還沒渲染、要不要多等一下——你會花很久才想到,問題其實是你手上的視窗從一開始就是錯的。
回傳錯誤的答案,比拋出例外糟糕得多。 例外會指向出問題的那一行;錯誤的答案會把你帶到一個完全無關的地方,然後讓你在那裡找一個不存在的原因。
每一種用來認視窗的條件,各自只描述視窗的一個面向:
| 條件 | 它真正指認的是 | 為什麼單獨不夠 |
|---|---|---|
process_names / pid |
處理程序 | app 自己的對話框也在同一個處理程序裡 |
window_classes |
視窗的種類 | #32770 遍地都是;WinUIDesktopWin32WindowClass 則所有 WinUI 3 app 共用 |
title_pattern |
顯示文字 | 會在地化、會帶檔名、啟動初期常常是空的 |
所以要組合。但 launch_and_discover 的預設是「任一條件命中就接受」,因為 title_pattern 的角色是類別與處理程序都沒中時的備援:
def matches(snap) -> bool:
checks = []
if classes:
checks.append(snap.class_name in classes)
if proc_names:
checks.append(get_process_image_name(snap.pid) in proc_names)
if compiled_re:
checks.append(bool(compiled_re.search(snap.title)))
if not checks:
return False
return all(checks) if require_all else any(checks)
在這個預設下,多加一個 window_classes 是放寬而不是收緊:現在「那個類別的任何視窗」或「那個處理程序的任何視窗」都算。同一個函式庫裡 Window.find 從一開始就是 matching ALL supplied criteria,而 launch_and_discover 不是——兩個名字很像的 API 用相反的組合語意,本身就是個陷阱。
Window.find:src/wintegrate/window.py#L895-L937
Window.launch_and_discover(
cmd, process_names=("Files.exe",),
window_classes=("WinUIDesktopWin32WindowClass",),
require_all=True, # ← 沒有這個,上面那行是白加的
)
require_all 的 docstring 直接把 Files 這個案例寫進去,讓讀 API 的人知道它在防什麼。
同一類的洞順手一起補:「所有條件都通過」對零個條件恆真,Window.find(timeout=0.3) 會回傳桌面上第一個可見視窗。這種呼叫幾乎不會是有人刻意寫的,最常見的來源是條件由變數帶入,而那個變數是 None。
if not any([title_exact, title_pattern, class_name, pid]):
raise ValueError("Window.find requires at least one search criterion")
同樣的檢查也在 find_descendant 上(src/wintegrate/element.py#L534-L559)。當呼叫端的意圖不可能是他寫出來的那樣時,要拋例外,而不是照字面執行。
修好了,接下來的問題是它會不會回來。有兩個回來的方式:有人把 require_all 的實作改壞,或有人覺得預設應該改成 AND、順手改了,然後靠 title_pattern 備援的呼叫全部斷掉。兩種都要有測試擋著。
用真環境當回歸測試不行。重現條件是「機器缺一個執行階段」,它有三個問題:
而 bug 只發生在其中一層。launch_and_discover 分兩層:底層問作業系統「桌面上有哪些視窗、各屬於哪個處理程序」(WindowCensus.capture、get_process_image_name),上層拿那份清單套條件挑一個。挑錯視窗完全發生在上層,輸入就是一份清單。 所以把當時桌面上的視窗清單直接寫成測試資料:
LAUNCH_DESKTOP = [
# The app's own error dialog: right process, wrong window.
WindowSnapshot(hwnd=11, title="Files.exe", class_name="#32770",
pid=900, is_visible=True),
WindowSnapshot(hwnd=12, title="Files",
class_name="WinUIDesktopWin32WindowClass",
pid=900, is_visible=True),
]
兩筆同一個 pid,這就是整個陷阱的形狀。fixture 把底層那兩個函式、Popen 與桌面切換全部換成假的,然後三個測試各釘一件事:
| 測試 | 斷言 | 它在釘什麼 |
|---|---|---|
只給 process_names |
拿到 hwnd == 11 |
陷阱本身。預設改成 AND 這裡會紅,逼改的人去看誰靠著備援 |
window_classes + require_all=True |
拿到 hwnd == 12 |
修法有效,對話框被排掉 |
require_all=True 配一個不存在的類別 |
拋 WindowDiscoveryTimeoutError |
修法失敗時往安全的方向倒:逾時,不能退而回傳錯的視窗 |
毫秒級、不需要 Windows、不需要 Files、不需要一台故意裝壞的機器。底層那兩個函式有沒有說實話,是另外的整合測試要管的事,兩邊分開各自便宜——Day 15 會專門講這個分層。
一個細節:斷言寫 win.hwnd == 11 而不是 win.class_name == "#32770",因為後者會拿一個只存在於 fixture 裡的 handle 去問真的作業系統。
擋不住的部分,至少要看得出來。這個案例裡最好的線索是時間:對話框 1.2 秒回傳,而 Files 真正的冷啟動要 19 秒。快一個數量級不是好消息。事件時間軸記完整的 float 秒而不是「大約幾秒」,就是為了讓這種反常看得出來。Day 16 會回到這件事。
今天的對話框不是孤例。探索回傳一個合法的視窗、而那個視窗不能用,這件事有一整個家族,每一種都通過了「有視窗」這一關:
| 拿到的是 | 為什麼看起來沒問題 | 哪裡講 |
|---|---|---|
| app 自己的錯誤對話框 | 處理程序對、可見、有標題 | 今天 |
| 在螢幕外的主視窗 | 可見、前景、UIA 樹完整 | 今天,下面 |
| 對的視窗,但完整性等級比你高 | 找得到、擁有它的處理程序對、SendInput 回傳成功 |
今天,下面 |
| 還沒填內容的殼 | 類別對、可見,只是標題還是空的 | Day 17 |
GDI+ Window、MSCTFIME UI 這類每次啟動都會出現的幽靈視窗 |
是新視窗、可見 | Day 17 |
| 被自己的 modal 蓋住的主視窗 | UIA 樹讀得到,只有輸入被攔走 | Day 5 |
| 被 DWM cloak 的視窗 | IsWindowVisible 是 True |
Day 29 |
症狀。 DB Browser for SQLite 的一個按鈕點下去毫無反應,沒有例外。查元素座標:
(-701, -525, -494, -469)
視窗本身也在螢幕外:在 800x600 的機器上還原成 (0, 0, 820, 620)——它記住了上次的位置與大小。
一個完全在螢幕外的視窗,除了點擊之外每一件事都正常。 它是可見的、是前景的、UIA 樹解析得出來、透過 pattern 的操作(select_verified() 走 SelectionItem)照樣成功。壞掉的只有點擊:負座標上的合成點擊落在虛無,click() 安靜返回,測試在後置條件上失敗——訊息裡沒有任何線索指向「游標從來沒到過那裡」。
根因。 app 記住的幾何超出這台機器的螢幕。會記位置的 app 在解析度比上次小的機器上啟動就會發生,CI runner 的 800x600 剛好就是那台比較小的機器。
修法。 開跑前把視窗搬回來:Window.ensure_onscreen()(src/wintegrate/window.py#L731-L783)。兩個細節:
SM_XVIRTUALSCREEN 那組系統度量去比,不是拿 0 去比。Day 4 截圖時用過同一組數字,當時就預告過它會再出現一次。它落在哪裡。 這一條的觸發條件跟 Files 那個相反:不是「環境缺東西」,而是 runner 自己的解析度,每次都一樣。所以它不需要假資料,直接進了兩個對著真 app 跑的測試:
test_the_window_can_be_brought_onscreen,斷言視窗搬得回來(tests/test_target_sqlitebrowser.py#L335-L344)。它不是在重現 bug,是在守住修法:不管這次的幾何是什麼,開跑前一定搬得回來。ensure_onscreen()(tests/test_regression_sqlitebrowser_3735.py#L151-L165)。那個測試的後置條件在剪貼簿裡,點不到格子就什麼都量不到。Day 27 會講它。第二個細節也決定了一件事:sqlitebrowser 有唯一 automation id 的按鈕全部在預設隱藏的 dock 裡,它們回報的「螢幕外」座標是「沒顯示」而不是「放錯地方」,ensure_onscreen() 救不了,所以那些按鈕最終沒有變成測項。這是量出來的結論,不是放棄。
SendInput 說成功,欄位是空的症狀。 2026 年 1 月的 Windows 更新之後,KeePassXC 的 Auto-Type 打不進「Windows 安全性」憑證提示。視窗找得到:類別 Credential Dialog Xaml Host,擁有它的處理程序是 CredentialUIBroker.exe,兩個條件都對、也是前景。SendInput 回傳送出的事件數,一個不少。然後欄位是空的。
根因。 那個提示的處理程序完整性等級是 0x200a,一般程式是 0x2000。UIPI 不讓低的往高的送輸入,而且丟掉時不告訴你:微軟文件明寫 SendInput 被 UIPI 擋下時,回傳值與 GetLastError 都不會反映這件事。這是這個家族裡最徹底的一種——不是視窗錯、不是位置錯,是你站的地方碰不到它,而每一個能問的 API 都說沒問題。
修法。 在 app 這邊,是一個有簽章、裝在 Program Files 底下的 uiAccess 小程式替它送鍵(上游 keepassxreboot/keepassxc#13648)。在測試這邊,能做的是把完整性等級拿來比:wintegrate 沒有這個 API,所以驗證腳本自己讀兩邊 token 的等級,對方高於自己就把這個事實寫進失敗訊息,而不是讓「送了、回傳成功、什麼都沒發生」留給下一個人猜(tests/ui-credui/verify_with_wintegrate.py#L339-L418)。
回歸。 這一條的正確答案是「打得進去」,而它唯一可信的證據是對照組:同一次 CI 上,修過的 build 對著真的憑證提示把欄位填滿,沒修的 build 用同一套腳本欄位保持空白,兩段錄影並排。只有修過的那段是綠的,證明不了任何事——SendInput 在兩邊都說成功。
require_all 要求它們描述同一個視窗SendInput 都會說謊明天講另一種「成功但沒做到」:你送出了操作,函式回傳 True,但 UI 其實還沒跟上。