這一天的內容是我在這整個系列裡錯得最徹底的一次,而且錯了三次。所以寫法會跟其他天不一樣:照時間順序,把每一個錯誤結論留在原位。
標的是 DB Browser for SQLite,Qt。
先講我一開始量到什麼。Qt 把 QAccessible 橋接到 UIA,覆蓋率意外地豐富:38 個 Button、11 個 Tab、4 個 Tree、6 個 ToolBar、19 個 Group。
而最大的收穫是 automation_id:
Application.MainWindow.centralwidget.mainTab.qt_tabwidget_stackedwidget
.browser.tabBrowsers.dockBrowse1.TableBrowser.dataTable
一個完整的物件路徑。 與語言無關、結構清楚、跨版本大致穩定。分頁的名稱是在地化的(瀏覽資料 而不是 Browse Data),但 automation_id 不是——這正是第四章講的東西,而 Qt 做得最好。
我在筆記裡寫下:「三個標的裡只有 Qt 有這個優勢。」
AssertionError: the main tab bar never reached 4 tabs (saw 0)
七個測試,六個掛在同一個 fixture 上。等了 60 秒還是 0 個分頁。
這是我的第一個念頭,而且聽起來很合理:Qt 的 QAccessible 是惰性初始化的,要有 client 來問才會建起來。CI 環境沒有螢幕閱讀器,也許就沒起來。
驗證的方法不是想,是量。 我讓 fixture 在失敗的時候 dump 整棵樹:
UIA descendants: 201
control types: button=41, list item=33, header=19, tree item=18, group=16,
tab item=11, text=8, window=8, menu item=6, tool bar=6, ...
automation ids (71 total): [...]
tab item=11——跟我在本機量到的一模一樣。樹是完整的,71 個元素有 automation_id。橋接完全正常。
第一個結論錯了,而糾正它的是在失敗訊息裡加一份 census。
windows-latest 是 Windows Server 2025 Datacenter,我的 VM 是 Windows 11。這個 repo 本來就有 desktop_only 與 server_only 兩個 pytest marker,代表 SKU 差異踩過。所以我把這個寫進了 module docstring。
也錯。 繼續 dump,這次印分頁的完整 automation id 而不只是尾段:
TabItem name='Browse Data' id=''
TabItem name='Database Structure' id=''
TabItem name='Execute SQL' id=''
id=''。全空。而本機是完整物件路徑。
第二個結論錯了,而糾正它的是印出完整的 id 而不是我以為夠用的那一段。
runner: class='Qt5152QWindowIcon'
我的 VM: class='Qt683QWindowIcon'
Qt 5.15.2 對 Qt 6.8.3。
去看官方 release 的壓縮檔裡有什麼:
DB.Browser.for.SQLite-v3.13.1-win64.zip → Qt5Core.dll
DB.Browser.for.SQLite-v3.13.1-win32.zip → Qt5Core.dll
DB.Browser.for.SQLite-v3.13.1-arm64.zip → Qt6Core.dll
同一個應用程式版本,每個架構配不同的 Qt。
我的 VM 是 ARM64,所以我裝到的是 Qt6 那份。而一般 x64 使用者拿到的是 Qt5。
換句話說:我筆記裡那句「Qt 的物件路徑是三個標的裡最好的優勢」,是在一個不具代表性的建置上量出來的。
這是我確認 Qt 版本之後的第一個提案。也錯——那等於放掉絕大多數真實使用者。兩個都要測。
| Qt 5.15.2 | Qt 6.8.3 | |
|---|---|---|
| 視窗類別 | Qt5152QWindowIcon |
Qt683QWindowIcon |
| 分頁列物件路徑 | 有,無 Application. 前綴 |
有,含前綴 |
| 分頁項目 id | '' |
分頁列的路徑 |
| 分頁項目 patterns | Invoke, Value |
+ SelectionItem |
bar.get_selection() |
[] |
可用 |
item.is_selected |
None |
可用 |
| 工具按鈕 id | 37 個中 21 個空 | 38 個中 19 個共用 QToolButton |
定位分頁列——對分頁列本身(不是項目)的物件路徑做 endswith:
MAIN_TAB_BAR = "centralwidget.mainTab.qt_tabwidget_tabbar"
Qt5 少了 Application. 前綴,所以 endswith 剛好兩種拼法都命中。我原本的 filter 掛在分頁「項目」上,而那個 Qt5 沒有——這就是原始的 bug。
備援(完全不依賴 id):三個分頁列裡,主文件區與 Remote dock 的是 QTabBar,dock 標題列是 QMainWindowTabBar。所以主分頁列 = 祖先鏈裡沒有 *Dock 的那個 QTabBar。Qt 的類別名稱不會被翻譯,兩邊都在。
選取——有 SelectionItem 就用 select_verified()(透過 pattern 驗證比較強),沒有就 click()。Qt5 上實測 click() 讓 Table 型別數量從 0 變 1。
驗證選取——這是最漂亮的一塊。兩個 build 都有的東西是 分頁列的 name,而它就是目前選中分頁的標籤:
點 [1] '瀏覽資料' -> bar.name='瀏覽資料' 吻合
點 [3] '執行 SQL' -> bar.name='執行 SQL' 吻合
點 [0] '資料庫結構' -> bar.name='資料庫結構' 吻合
點 [2] '編輯 Pragmas' -> bar.name='編輯 Pragmas' 吻合
四次全中。而且這個比較與語言無關——兩個字串都來自同一個來源。
同一個主題還有第二個版本,而這一次我猜了兩次,兩次都錯。
arm64 job 第一版用套件管理員裝 x64 建置,在 ARM 上跑模擬。三個症狀:
length=0、lines=1、is_modified=False、get_value()==''——而每一個元素都解析得出來、視窗看起來完全正常。11 個測試裡 7 個倒。Next Difference 的點擊讓狀態列沒有變化。focus_content_island() 回 False,7 個測試全部倒在 fixture 上。三個不同的應用程式、三種不同的症狀。而「模擬下的行為不同」聽起來完全合理,而且跟 Qt 那一題是同一個形狀——兩者都有官方 arm64 安裝檔,所以我換成原生建置。
Notepad++ 換了之後還是一樣紅。
去查我自己 VM 上那份(測試在那裡全過)的 PE 標頭:
notepad++.exe machine = ARM64
version = 8.9.8.0
它本來就是 ARM64 原生。 所以「模擬」從來不是 Notepad++ 那組失敗的原因——同一個原生建置在我的 VM 上完全正常,在 runner 上完全收不到輸入。
真正的差別在別的地方,而症狀早就指向它了:length=0 加上「Ctrl+F 沒有開出對話框」不是「編輯器壞了」,是輸入根本沒有到這個應用程式。fixture 裡那一行是這樣寫的:
with win.foreground(verify=False):
verify=False。這個 suite 到處都這樣寫,因為前景請求會跟桌面上其他事情搶——而在這裡它讓「有另一個視窗擋在前面」變成了一個沒有訊息的失敗。
修法不是加 sleep,是讓失敗說出是誰握著前景:
assert _foreground_settled(win), (
"Notepad++ never became the foreground window; "
f"{_describe_foreground()} has it instead, so every keystroke "
"below would have gone there"
)
下一次 CI 就把答案印出來了:
AssertionError: 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
一個「Microsoft account」的系統提示視窗,從開機就握著前景,而且不會自己走。 它在我的 VM 上不存在,所以在那裡什麼問題都沒有。
而它同時解釋了最早那次模擬環境的失敗——也就是說,我對那組失敗的兩個解釋(先是模擬、後是原生建置)都不是原因。兩次都是同一個視窗擋在前面。
(CI 上的處理是在測試前把它關掉,而且是按視窗類別而不是處理程序名稱去找——一個 CoreWindow 背後的處理程序不會是你猜的那一個。)
這跟 Qt 那一題的結構完全一樣:我有一個合理的解釋,它解釋得通所有已知症狀,而它是錯的。糾正它的不是更好的推理,是讓失敗訊息多說一句話。
| 症狀 | 為什麼 |
|---|---|
| Notepad++ 什麼都沒打進去 | 按鍵全部送到那個視窗 |
| WinMerge 的按鈕點擊無效 | 合成點擊被前景視窗攔走 |
Files 的 focus_content_island() 回 False |
另一個視窗握著前景時,OS 拒絕對背景視窗的元素 SetFocus |
關掉它之後,三個 job 全綠。一個原因,三個看起來完全不同的症狀。
第三列是最有意思的一列,因為它連「我以為跟輸入無關的東西」都涵蓋了:SetFocus 不是輸入操作,但作業系統對背景視窗的元素會拒絕它——而拒絕的方式是回報失敗,不是拋出可辨識的錯誤。
對 WinMerge 成立——原生 arm64 建置確實修好了它那一個失敗。而且「架構決定你拿到哪個建置」在 DB Browser 那一題是硬事實:
x64 與 arm64 不是「同一個測試在兩台機器上跑」,是兩個不同的程式。
只是它不會是每一個 arm64 失敗的原因。而我在只有三個症狀、沒有任何診斷輸出的情況下,就把它當成了唯一的解釋。
這一天的結論不是關於 Qt,是關於「一個測試的環境要指定到多細」。答案比我原本以為的細:
| 要釘 | 為什麼 |
|---|---|
| 應用程式版本 | 顏色值、automation id、分頁數都是對著一個 build 量的 |
| 架構 | 決定你拿到哪一個建置 |
| 原生或模擬 | 模擬下的行為可以完全不同 |
| client / server SKU | 這個 repo 早就有兩個 marker 在處理它 |
而且要釘在兩個地方並且互相檢查:CI 的安裝步驟指定版本,測試模組裡也宣告一個 VERIFIED_VERSION,再加一個測試比對實際安裝的版本。不然這兩件事會默默漂開。
(版本從 file version 的數值欄位讀,不要讀 FileVersion 字串——DB Browser 自己寫的是 '3.13.1.',尾巴一個點、沒有第四段。)
name 驗證選取SetFocus 被拒絕。我對它猜了兩次(模擬、原生建置),兩次都錯第五章剩下三天:兩天回到 UIA 的複雜控制項,最後一天用這四個 app 去抓四個真實的 bug。