昨天的問題是「等不夠久」。今天的問題更奇怪:等再久也沒有用,因為那個視窗永遠不會出現。
before = WindowCensus.capture()
subprocess.Popen(["notepad.exe"])
after = WindowCensus.capture()
diff = WindowCensus.diff(before, after)
# diff.added 是空的
處理程序建立了,記事本畫面也真的出現了,但 diff 說「沒有新視窗」。
原因是:上一個測試留下的記事本還開著。
Windows 11 的記事本是單例應用。你再啟動一次,它不會開新視窗——它把既有的視窗叫到前景。從 WindowCensus 的角度,桌面上的視窗集合完全沒有變化,所以 diff.added 是空的。
而 Store App 的單例行為是套件層級的——kill 掉一個處理程序不夠。但它有解,而且解法就在檔案系統上,這是本篇後半的重點。
本機開發時你會看到那個殘留視窗,一眼就知道發生什麼事。CI 上你看不到——你只會拿到一個逾時,而且是那種「等好久才失敗」的逾時(Day 16)。
更麻煩的是污染會累積。第 3 個測試留下一個記事本,第 7 個測試拿到它、在裡面打了字、也沒關掉,第 12 個測試拿到一個裡面有前面兩個測試殘骸的視窗。
而 Store 版記事本還會還原上次的內容——它有 session 還原功能。所以即使那個處理程序真的被關掉了,下次開起來裡面還是有上次的文字。
於是你的斷言變成:
assert edit.get_value() == "hello"
# AssertionError: assert 'previous test texthello' == 'hello'
一個看起來像「打字功能壞了」的失敗,實際原因是三個測試以前的殘留。
session.app() 有一個 fresh 參數:
session.app(NOTEPAD, fresh="auto")
"auto" 的意思是:在 CI 上先掃掉既有的實例,本機不要。
為什麼要分?因為本機跑測試時,你可能正好開著記事本在寫東西。一個測試框架把使用者的視窗關掉是不可接受的。CI runner 是拋棄式的,沒有這個顧慮。
這個「同一個設定在 CI 和本機上答案相反」的模式,我們在 Day 2 講虛擬桌面隔離時已經看過一次了。它會一直出現,因為CI 和開發機在「這台機器屬於誰」這件事上根本不同。
我第一版的清掃是這樣寫的:
kill_processes(spec.process_names)
time.sleep(0.3) # ← 我自己寫的
Day 8 整篇在講「不要用 sleep,要輪詢後置條件」,然後清掃程式碼裡放了一個 sleep(0.3)。一個人手動清場一定會做的事——殺完看一眼視窗還在不在——這段程式碼沒有做。清理是最容易被當成「不重要」而少掉那一眼的程式碼,也就是最需要驗證的那一段。
而它真的會壞:終止一個打包應用是非同步且套件層級的,taskkill 回傳的時候最後一個視窗還沒拆完。0.3 秒在 ARM64 上不一定夠,而沒有任何東西去確認。一個活過清掃的視窗,讓下一次啟動產生不出新視窗——於是逾時,而逾時值再怎麼調都沒用。
改成驗證式的之後,整套測試從 128 秒降到 90 秒——因為不再等那些猜出來的間隔。
即使確認視窗都消失了,還是會偶發失敗。原因我找了很久,最後在檔案系統上:
%LOCALAPPDATA%\Packages\Microsoft.WindowsNotepad_8wekyb3d8bbwe\LocalState\
├── TabState\ ← 三個 .bin,其中一個 1062 bytes
└── WindowState\
那 1062 bytes 是前面幾個測試累積下來的文字。
Store 版記事本把開著的分頁存在磁碟上,下次啟動讀回來。所以「終止處理程序」永遠不夠——你殺掉的是處理程序,留下的是狀態。
我一度把這個現象歸因成「單例特性,沒辦法」。那是放棄得太早:狀態就明擺在檔案系統上,可以刪。
package_family_name = "Microsoft.WindowsNotepad_8wekyb3d8bbwe"
session_state_dirs = ("TabState", "WindowState")
有一個順序上的細節:必須等視窗都消失之後才刪。太早刪沒有用,那些檔案還被開著(實測會拿到 PermissionError)。而刪除要容許失敗並重試——垂死的處理程序可能還握著 handle,而殘留一個檔案不影響下一次啟動是空的。
這一個我是被咬到才想到的,值得單獨講,因為它示範了「全域狀態」可以躲在多不起眼的地方。
把 IME 那組測試重構成 with 之後,它們失敗了:
AssertionError: assert 'HELLO' == 'hello'
AssertionError: assert 'aB' == 'Ab'
大小寫 被反轉 了。原因是 Caps Lock 開著——我前面某支探測腳本按到,而它就一直留在那裡。
想想這個失敗訊息會把人帶到哪裡。它指向「掃描碼注入」:是不是 Shift 的處理錯了?是不是 ARM64 又在吃鍵?你會去查按鍵送出的程式碼,而那裡從頭到尾都對。真正的原因是幾小時前一次誤觸。
Caps Lock 的性質是:
所以打字前該確定的輸入狀態不只有輸入法模式,還有它。現在 ime_mode 兩個都管:
with dialog.ime_mode(ImeConversion.ALPHANUMERIC):
edit.send_physical_keys("hello") # 模式是英數,而且 Caps Lock 是關的
# 兩者都還原
一般化的教訓: 當你要「建立確定的輸入狀態」時,清單比你以為的長。 而找出漏掉哪一項的方法,通常是被它咬一次——所以咬過之後要把它寫進程式碼,不是寫進記憶。
四個步驟,缺一不可:
實測效果,同一台 zh-TW ARM64 機器連跑五次完整測試:
| 結果 | 整套時間 | |
|---|---|---|
| 原始(sleep 式清掃) | 3/3 都有失敗 | 128 秒 |
| 改成驗證式清掃 | 4/5 | 90–111 秒 |
| 加上清除 session 狀態 | 5/5 | 穩定 80 秒 |
最後一個失敗模式,錯誤訊息長這樣:
No text-input element found in window '' within 20.0s
注意標題是空字串。
一個頂層視窗會在被填充之前就變成可見:類別已經符合、is_visible 是 True,但標題還是空的、內容子視窗還不存在。探索把這個殼交出去,看起來成功,然後 20 秒後在 find_text_input 裡失敗——而你會去查輸入區的搜尋條件,那裡完全沒問題。
修法是把「無標題的符合項」當成還沒好,繼續輪詢:
def is_ready(snap) -> bool:
return bool(snap.title.strip())
以及讓逾時訊息說出這件事:「有一個符合的視窗但它從未取得標題,代表它還在初始化。」
這又回到 Day 8:探索也是一個需要驗證後置條件的操作。 「有一個視窗出現了」不是你要的後置條件,「有一個可用的視窗出現了」才是。
上面四步都做對了,還有一種「啟動了但沒有新視窗」——而且它只在 Store 版的 app 上、只在某些 runner 上發生。
Store 把下載好的更新等 app 關閉的那一刻套用。所以前一個測試關掉 Notepad,正好就是套件被換掉的時刻;下一個測試啟動 notepad.exe,撞上換包中,30 秒沒有任何視窗。windows-11-arm 映像上的 Notepad 版本落後(實測 11.2512.29.0),所以 runner 一開機 Store 就去抓更新;windows-latest 是傳統 notepad.exe,Store 不碰它。
它跟前面四種的差別是沒有任何殘留可以清——沒有行程、沒有視窗、沒有 LocalState 檔案。唯一的線索在錄影裡:前一個測試的 Notepad 頂端有一條「A new version of Notepad is available」的橫幅。Day 28 講 runner 端怎麼壓住它,Day 29 講失敗訊息怎麼把它說出來。
一個人手動啟動一個 app,如果它跳出一行錯誤然後消失,人會讀那一行。自動化的探索沒有:子行程繼承了 step 的 stdout/stderr,那一行錯誤混進 runner 的 console 輸出裡沒有標籤,或者根本沒地方去;探索只回報「30 秒內沒有新視窗」。
現在在 Session 裡啟動的子行程,stdout/stderr 各自寫到 artifact 目錄的 launched_NN.out / .err,探索逾時時把非空的那個引進錯誤訊息裡(_child_output_note,最多八行,多的說有幾行在哪個檔)。VM 上的正控:一個印了 hello from child 到 stdout、boom on stderr 到 stderr 然後 exit 3 的子行程,逾時訊息兩句都引到了,launched_01.out 裡就是那一行。CI 上正常啟動的 Notepad 留下的是兩個 0 byte 的檔——空檔不會出現在訊息裡,但它在那裡,代表「它什麼都沒說」也是量出來的。
即使清乾淨了,「diff 出來的新視窗」也不是萬無一失。所以探索邏輯有兩段:
# 第一段:找新出現的視窗
for snap in diff.added:
if snap.is_visible and snap.hwnd not in excluded and not is_ignorable_helper(snap):
if has_criteria and not matches(snap):
continue
return proc, cls(snap.hwnd, snap.pid)
# 第二段:退而求其次,找目前所有符合條件、且不在 before 裡的視窗
for snap in after:
if (snap.is_visible
and snap.hwnd not in excluded
and snap.hwnd not in {b.hwnd for b in before}
and not is_ignorable_helper(snap)):
if has_criteria and matches(snap):
return proc, cls(snap.hwnd, snap.pid)
注意第二段仍然排除 before 裡的 hwnd——它不是放棄原則,只是換一個角度計算「新」。
上面兩段都出現了 is_ignorable_helper。這是因為當你啟動一個應用程式,桌面上冒出來的不只是你要的那個視窗。
def is_ignorable_helper(snap) -> bool:
title = snap.title.lower()
cls_name = snap.class_name.lower()
if "gdi+ window" in title or "gdi+ hook window class" in cls_name:
return True
if "msctfime ui" in title or "msctfime ui" in cls_name:
return True
if "default ime" in title or "ime" == cls_name:
return True
return False
這三類是什麼:
| 名稱 | 來源 |
|---|---|
GDI+ Window |
GDI+ 初始化時建立的內部訊息視窗 |
MSCTFIME UI |
TSF 文字服務的介面視窗 |
Default IME |
每個有輸入需求的執行緒都會有一個 |
它們有共同特徵:是頂層視窗、技術上「可見」、但使用者從來看不到它們。 它們是實作細節洩漏到視窗列舉裡的產物。
如果不過濾,你的探索會很開心地回傳一個 Default IME 視窗——然後你在上面找元素,一個都找不到。又是一個「找到了但找到錯的東西」(Day 7)。
必須誠實:這是一份黑名單,而黑名單永遠不完整。
我曾經懷疑過這個做法對不對——直接忽略掉這些東西,會不會哪天忽略掉真正重要的視窗?
結論是:在這個特定情境下可以接受,因為這三類視窗有明確的特徵、而且它們在任何應用程式上都會出現。它們不是某個 app 的特例,是 Windows 的基礎設施。
但要配套兩件事:
window_census.json,這樣事後回頭看時,你知道當時有哪些東西被跳過了。黑名單可以用,但它必須是可見的、可推翻的。 一個藏在程式碼裡、悄悄丟掉東西的過濾器,遲早會吃掉一個你需要的東西,而你查不出來。
還有一個相關的坑,wintegrate 的 README 列在陷阱清單裡:
Launcher PID != Window PID: Modern packaged Windows apps (like Notepad, Terminal) launch a starter shim process that does not own the visible HWND.
你 Popen 拿到的 PID,跟那個視窗真正的擁有者可能不是同一個。打包應用會先跑一個很短命的 shim,由它去請求系統啟動真正的應用程式,然後自己結束。
所以「用 PID 過濾視窗」這個看似最可靠的做法,在現代 Windows 應用上是不可靠的。這就是為什麼探索要靠 before/after diff 加上多重條件,而不是單靠 PID。
LocalState\TabState,殺掉處理程序不會清掉它
launched_NN.out/.err,逾時訊息會把它引出來——人會讀那一行錯誤,流程以前沒地方讀sleep(0.3)
fresh="auto":CI 上先清、本機不清——因為那台機器不屬於你GDI+ Window、MSCTFIME UI、Default IME 是永遠會出現的幽靈視窗明天講另一個看似可靠、實際上會害你的識別方式:用視窗標題找視窗。在一台中文 Windows 上,記事本不叫 Notepad。