第四章開始。這一章講的都是同一件事的不同面向:你在自己機器上調出來的參數,在 CI 上是錯的。
同一份測試程式碼、同樣啟動記事本(Notepad)、同樣等待主視窗出現。從送出啟動指令到 UIA 探索到該視窗:
| Runner 環境 | 作業系統 SKU | launch_app → window_discovered |
|---|---|---|
windows-latest(x64) |
Windows Server 2025 | 0.10 秒 |
windows-11-arm(ARM64) |
Windows 11 Client | 7.84 秒(一般冷啟動) / 17.18 秒(高負載峰值) |
近百倍的差距。
初次看到這個數字的人,直覺往往會得出一個看似合理的推論:「ARM64 上有模擬層,而且雲端虛擬機的 I/O 太慢,架構差異導致了近百倍的效能差距。」
這個推論從頭到尾都是錯的。
在自動化測試與跨平台驗證中,有一條非常殘酷的真實法則:SKU 差異往往比架構差異更致命。
這近百倍的差距,根本不是「x64 比 ARM64 快 80 倍」,而是你在兩個環境裡啟動的根本是完全不同的兩個軟體:
windows-latest 執行的是傳統 Win32 記事本GitHub Actions 的 windows-latest 映像底層是 Windows Server(例如 Windows Server 2022 或 2025)。在實際的 CI job log 中,檢查套件會直接印出:
Microsoft.WindowsNotepad: not a packaged app on this image
在 Server SKU 上,C:\Windows\System32\notepad.exe 是一個只有幾百 KB 的純 C / Win32 傳統桌面應用程式。沒有現代 UI 框架、沒有套件容器、沒有任何額外相依性,建立處理程序到刻出視窗只需要 0.10 秒。
windows-11-arm 執行的是打包的 WinUI 3 現代應用windows-11-arm 執行的是 Windows 11 Client。在 CI 的預備步驟日誌中,它清楚回報:
Microsoft.WindowsNotepad: 11.2512.29.0 [Ok]
在 Windows 11 上,記事本早就被改寫為現代的 MSIX 打包應用程式(Microsoft.WindowsNotepad),而且在 ARM64 runner 上它也是 100% 原生的 ARM64 二進位檔,根本沒有走任何 Prism x64 模擬。
更關鍵的是,你在命令列敲下 notepad.exe 時,它並不是直接載入本體:
C:\Windows\System32\notepad.exe 在 Win11 下只是一個約 300 KB 的代理啟動器(Launcher)。它透過系統的 Execution Alias 機制,將啟動請求轉發給 Windows 的套件子系統。AppXSvc)會偵測並在背景下載新版套件,若疊加記憶體分頁緊縮,啟動耗時會飆升至 17.18 秒;更甚者,若前一個測試剛好關閉記事本,Store 趁機換包,新啟動請求會直接卡在背景等待(如 run 33956741564),整整 30 秒都無法產生任何視窗:WindowDiscoveryTimeoutError: No new window appeared within 30.0s (cmd=['notepad.exe'])
Visible windows at the moment discovery gave up (11 total):
class='#32770' pid=6508 title='Performance Options'
class='DummyDWMListenerWindow' pid=880 title=''
class='Shell_TrayWnd' pid=880 title=''
(nothing new appeared at all - the launch produced no window)
0.10 秒對 7.84 秒(乃至極端情況的 17 秒),比的不是 CPU 架構,而是一個 0 依賴的三十歲 Win32 程式 與一個 走完整現代生命週期、在冷機上初次載入且隨時面臨 Store 換包的 WinUI 3 打包應用。
如果在自己的開發機上調測,你看到的往往是一台「暖過機」的系統:套件已載入過快取、字型快取早已就緒、Store 沒有在背後抓更新。在你的筆電上,Win11 記事本可能 0.5 秒就開好了。
於是你直覺地寫下一個自以為寬鬆的等待:
window = Window.find(title_pattern="Notepad", timeout=5.0)
五秒,比平常量到的慢了整整十倍,任何人審 code 都會覺得綽綽有餘。
但在全新的 Windows 11 CI runner 上,這行程式碼必死無疑。它會在 5.0 秒準時噴出:
WindowDiscoveryTimeoutError: Window failed to appear within 5.0s
而這類失敗最陰險的地方在於它的間歇性:
AppXSvc 換包或冷啟動峰值,它就耗時 17 秒而逾時。工程師看到「有時綠、有時紅」,直覺反應就是給測試加上 @pytest.mark.flaky(reruns=3),或者歸咎於「Windows CI 就是不穩定」。
但它根本不是隨機的不穩定,它是逾時預算從一開始就給錯了。 間歇性失敗是症狀,不是診斷。把系統性延遲誤診成 flaky,代價就是真正的環境問題永遠不會被看見。
託管啟動的預設探索逾時應該直接給出足夠寬裕的地板值:
session.app(NOTEPAD) # 預設 30 秒探索逾時
30 秒對「開一個記事本」來說荒謬嗎?在你的開發機上是。在剛開機的 Windows 11 ARM64 runner 上,它是覆蓋 7.84 秒常態冷啟動、17 秒資源緊縮峰值,以及背景換包競爭的合理餘裕。
這裡有個常見的反對意見:「逾時設這麼長,測試會不會變很慢?」
不會。
逾時是上界,不是等待時間。 視窗探索是輪詢的——一旦視窗出現就立即返回。在 windows-latest 上它 0.10 秒就回來了,那個 30 秒從來沒有被消耗過。
只有在真的失敗(程式崩潰、名稱拼錯、或真的死鎖)時,測試才會等到 30 秒。而那時候,你本來就必須付出等待成本——差別只在於你付出的是「30 秒後得到確切的失敗結論」,還是「5 秒後得到一個誤導人的偽陽性逾時」。
寬鬆的啟動逾時不會讓成功的測試變慢一毫秒,只會讓失敗的測試多花一點時間。在 CI 預算裡,這個交換幾乎永遠划算。
30 秒這個數字不是憑空捏造的,而是從 session_events.json(Day 4)實際計算出來的:
launch = next(e for e in events if e["type"] == "launch_app")
found = next(e for e in events if e["type"] == "window_discovered")
print(f"啟動到視窗就緒耗時:{found['timestamp'] - launch['timestamp']:.2f} 秒")
這就是第一章那些診斷產物的回報。有了每次 run 精確到浮點數秒的實測數字,「逾時該設多少」從無意義的猜測變成查表動作。
而且它是持續可觀測的:哪天 runner 的映像換了、記事本更新了、啟動時間漂移到 25 秒,你會在數字上第一時間看到趨勢,而不是在不明不白的測試失敗率裡瞎猜。
許多自動化腳本習慣全域設一個 timeout=10 到處套用。這會導致兩頭落空:啟動應用時太短,控制項操作時又太長。
合理的逾時應該依據底層牽動的作業系統層級分級:
| 操作類型 | 底層發生的事 | 合理逾時量級 |
|---|---|---|
| 啟動應用程式並探索主視窗 | AppContainer 沙盒、DCOM 啟用、執行階段載入、處理程序樹初始化 | 30 秒 |
| 元素操作的後置條件驗證(Day 8) | UI 訊息迴圈處理、控制項重繪、內部狀態變更 | 2–3 秒 |
| 尋找已經存在的視窗或控制項 | UIA 樹走訪、快取查詢 | 1–2 秒 |
等一個按鈕反白,是等一個 UI 訊息迴圈跑一圈(毫秒級);等一個現代應用程式冷啟動,是等作業系統完成成百上千個 I/O、權限檢查與框架載入(秒級)。把它們給同一個逾時值,只會讓短的那個容易誤殺啟動,長的那個讓每次找不到元素都要枯等半分鐘。
記住精確時間戳還有另一個至關重要的用途:不只太慢是問題,太快往往是致命訊號。
Day 7 提過一個典型案例:某個應用程式在全新環境下,真正的冷啟動與刻出主視窗至少要 19 秒。但在某次 CI run 中,探索函式竟然在 1.2 秒 內就回報「找到視窗」了。
那 1.2 秒絕對不是效能突飛猛進——它找到的是應用程式因為缺少執行階段相依性而自己彈出的錯誤對話框。
如果測試的尋找條件寫得太寬鬆(例如只依據處理程序名稱比對),測試就會把這個錯誤視窗誤當成主畫面,接著在下一步找不到任何操作元素而莫名其妙崩潰。
所以「這一步花了多久」在兩個方向上都具有判斷力:
| 時間特徵 | 現場可能發生的事 |
|---|---|
| 比預期久很多 | 冷啟動、套件安裝換包中、資源競爭、或前景被系統視窗劫持 |
| 比預期快很多 | 你找到的根本不是主畫面,而是崩潰回報或錯誤對話框 |
時間不是單純的計時器,它本身就是具有判斷力的斷言依據。
明天講另一種冷啟動失敗:測試明明啟動了應用程式,探索卻根本沒看到新視窗——因為上一輪的實例還活著。