iT邦幫忙

2026 iThome 鐵人賽

DAY 5
0

前面三天都在講怎麼產出證據:錄影、截圖、視窗普查。
這一天講一件更基本的事——什麼時候該停止推理,去看證據。

因為我很不擅長這件事。這個系列裡我有一連串錯誤結論,
而它們幾乎都有同一個形狀:一個合理的解釋,解釋得通所有已知症狀,而它是錯的。


一個沒有線索的失敗

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 的檔案總管)。而在同一次執行裡:

  • 視窗找到了,類別正確
  • 分頁列讀得到,數量是 1
  • 路徑欄位讀得到,值正確
  • 導覽測試通過

所以應用程式活著、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 上,三個不同的應用程式同時壞掉,方式完全不同:

  • Notepad++:打進去的字完全沒有到編輯器。length=0get_value()==''
    而每一個元素都解析得出來。
  • WinMerge:九個測試裡八個過,只有一個按鈕點擊沒有效果。
  • Filesfocus_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 裡的按鈕 成功

第四列是這張表裡最重要的一列:它是一個會通過天真檢查的失敗。


小結

  • 我的錯誤結論幾乎都是「一個合理的解釋,解釋得通所有已知症狀,而它是錯的」
  • 四個沒有線索的紅測試,答案在錄影的一格畫面裡:一個蓋在自家視窗上的 modal
  • modal 完全不影響 UIA——樹讀得到,只有輸入被攔走
  • 「只在檔案存在時才修改」在唯一需要它的環境裡不動作
  • 把 census dump 進失敗訊息,十行程式碼省掉一條錯誤路線
  • 診斷輸出的解析度決定它能推翻哪些假設——印尾段不夠,要印完整的
  • 一個原因可以有三個看起來無關的症狀——一個握著前景的系統提示視窗,
    同時造成「打不進字」、「按鈕點擊無效」、「SetFocus 被拒絕」
  • 每一個你關掉的驗證(verify=False),都是一個你選擇不知道的事實
  • 驗證條件寫錯比沒有驗證更糟:「焦點動了嗎」會對一個無效的操作回報成功

第二章開始進入 UI Automation 本身,而第一件事是:它不是一個 API,是 COM。


上一篇
Day 4:「成功回傳」不等於「拿到有用的東西」
系列文
Windows 桌面軟體 CI 實戰:從系統工具開發到 Zero-RDP 自動化測試錄影5
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言