iT邦幫忙

2026 iThome 鐵人賽

DAY 7
0
Software Development

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

Day 7:對的 PID,錯的 HWND——app 自己的錯誤對話框也在它的處理程序裡

  • 分享至 

  • xImage
  •  

昨天的問題是「找不到」,至少它會誠實地失敗。今天這個更陰險:它找到了,回傳了,測試繼續跑,然後在別的地方以看不懂的方式壞掉。


症狀:1.2 秒回傳的視窗

我在一台沒裝 .NET 10 Desktop Runtime 的 ARM64 機器上啟動 Files(WinUI 3)。launch_and_discover1248 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、有 pidexists()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)

相關原始碼:src/wintegrate/window.py#L940-L992

在這個預設下,多加一個 window_classes放寬而不是收緊:現在「那個類別的任何視窗」或「那個處理程序的任何視窗」都算。同一個函式庫裡 Window.find 從一開始就是 matching ALL supplied criteria,而 launch_and_discover 不是——兩個名字很像的 API 用相反的組合語意,本身就是個陷阱。

Window.findsrc/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 備援的呼叫全部斷掉。兩種都要有測試擋著。

用真環境當回歸測試不行。重現條件是「機器缺一個執行階段」,它有三個問題:

  • 它是環境的缺陷,不是程式的輸入。runner 映像哪天預裝了 .NET 10,對話框就再也不會出現,測試變成永遠綠——回歸測試自己先回歸了
  • 每次要等 app 啟動,結果混進啟動時間、runner 忙不忙、對話框有沒有及時出現。紅了不知道是比對邏輯壞了還是環境不對。
  • 只能在 Windows 上跑。

而 bug 只發生在其中一層。launch_and_discover 分兩層:底層問作業系統「桌面上有哪些視窗、各屬於哪個處理程序」(WindowCensus.captureget_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 修法失敗時往安全的方向倒:逾時,不能退而回傳錯的視窗

測試:tests/test_find_semantics.py#L64-L132

毫秒級、不需要 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+ WindowMSCTFIME 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 截圖時用過同一組數字,當時就預告過它會再出現一次。
  • 隱藏的 widget 也會回報螢幕外座標,但那是另一回事。 Qt 給收起來的 dock 的子元素一個離視窗很遠的座標,所以「這個矩形在螢幕外」可能是「這個控制項現在沒顯示」而不是「視窗放錯地方」。

它落在哪裡。 這一條的觸發條件跟 Files 那個相反:不是「環境缺東西」,而是 runner 自己的解析度,每次都一樣。所以它不需要假資料,直接進了兩個對著真 app 跑的測試:

  • 標的測試套件裡有一條 test_the_window_can_be_brought_onscreen,斷言視窗搬得回來(tests/test_target_sqlitebrowser.py#L335-L344)。它不是在重現 bug,是在守住修法:不管這次的幾何是什麼,開跑前一定搬得回來。
  • 重現 upstream bug #3735 的測試在探索之後、任何點擊之前先呼叫 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 其實還沒跟上。


上一篇
Day 6:`FindFirst` 跨不過巢狀 HWND,逐層走訪跨得過去
下一篇
Day 8:回傳成功,不代表已經發生——UI 操作是非同步的
系列文
Windows 桌面軟體 CI 實戰:從系統工具開發到 Zero-RDP 自動化測試錄影25
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言