iT邦幫忙

2026 iThome 鐵人賽

DAY 17
0
Software Development

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

Day 17:啟動了,但沒有新視窗——單例應用與幽靈視窗

  • 分享至 

  • xImage
  •  

昨天的問題是「等不夠久」。今天的問題更奇怪:等再久也沒有用,因為那個視窗永遠不會出現。


症狀

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 上更糟

本機開發時你會看到那個殘留視窗,一眼就知道發生什麼事。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

我第一版的清掃是這樣寫的:

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,而殘留一個檔案不影響下一次啟動是空的。

順帶一提:一個沒有人擁有的切換鍵(Caps Lock)

這一個我是被咬到才想到的,值得單獨講,因為它示範了「全域狀態」可以躲在多不起眼的地方。

把 IME 那組測試重構成 with 之後,它們失敗了:

AssertionError: assert 'HELLO' == 'hello'
AssertionError: assert 'aB' == 'Ab'

大小寫 被反轉 了。原因是 Caps Lock 開著——我前面某支探測腳本按到,而它就一直留在那裡。

想想這個失敗訊息會把人帶到哪裡。它指向「掃描碼注入」:是不是 Shift 的處理錯了?是不是 ARM64 又在吃鍵?你會去查按鍵送出的程式碼,而那裡從頭到尾都對。真正的原因是幾小時前一次誤觸。

Caps Lock 的性質是:

  • 桌面全域,跨處理程序、跨測試、跨 session
  • 沒有明確的擁有者——沒有任何測試「負責」它
  • 會存活下來——重開應用程式、切換視窗都不會重設
  • 而且它 只影響字母,所以功能鍵的測試全部通過,只有打字的測試壞掉

所以打字前該確定的輸入狀態不只有輸入法模式,還有它。現在 ime_mode 兩個都管:

with dialog.ime_mode(ImeConversion.ALPHANUMERIC):
    edit.send_physical_keys("hello")     # 模式是英數,而且 Caps Lock 是關的
# 兩者都還原

一般化的教訓: 當你要「建立確定的輸入狀態」時,清單比你以為的長。 而找出漏掉哪一項的方法,通常是被它咬一次——所以咬過之後要把它寫進程式碼,不是寫進記憶。


完整配方

四個步驟,缺一不可:

  1. 終止處理程序
  2. 等到符合條件的視窗真的消失(不是 sleep)
  3. 清除套件的 session 狀態
  4. 啟動,並確認視窗就緒才交出去(下一節)

實測效果,同一台 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 正在換掉它

上面四步都做對了,還有一種「啟動了但沒有新視窗」——而且它只在 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 的基礎設施。

但要配套兩件事:

  1. 不要靜靜地忽略。 被過濾掉的視窗要記進 window_census.json,這樣事後回頭看時,你知道當時有哪些東西被跳過了。
  2. 保留覆寫的能力。 如果有人的應用程式真的叫做「Default IME」(雖然難以想像),他要能明確指定。

黑名單可以用,但它必須是可見的、可推翻的。 一個藏在程式碼裡、悄悄丟掉東西的過濾器,遲早會吃掉一個你需要的東西,而你查不出來。


順帶一提:啟動器 PID ≠ 視窗 PID

還有一個相關的坑,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。


小結

  • 單例應用再啟動不會產生新視窗,diff 會是空的
  • Store App 把分頁存在 LocalState\TabState殺掉處理程序不會清掉它
  • 乾淨啟動的完整配方是四步:終止 → 等視窗消失 → 清除 session 狀態 → 啟動並確認就緒
  • 第五種沒有殘留可清:Store 在 app 關閉那一刻換包,下一次啟動撞上換包中
  • 第六種是它自己說了:子行程的 stdout/stderr 寫到 launched_NN.out/.err,逾時訊息會把它引出來——人會讀那一行錯誤,流程以前沒地方讀
  • 清掃要輪詢確認,不要 sleep——我自己在這裡寫了 sleep(0.3)
  • 視窗可見不等於就緒:標題還是空的代表它還在初始化
  • Caps Lock 是桌面全域、沒有擁有者、而且會存活——打字前要一起建立
  • fresh="auto":CI 上先清、本機不清——因為那台機器不屬於你
  • 探索要有備案,但備案不能放棄「新」這個條件
  • GDI+ WindowMSCTFIME UIDefault IME 是永遠會出現的幽靈視窗
  • 黑名單可以用,但必須可見(記進 artifacts)且可推翻
  • 打包應用的啟動器 PID 不等於視窗 PID

明天講另一個看似可靠、實際上會害你的識別方式:用視窗標題找視窗。在一台中文 Windows 上,記事本不叫 Notepad。


上一篇
Day 16:0.10 秒 vs 7.84 秒——冷啟動比任何人的預算都久
下一篇
Day 18:不要用視窗標題找視窗
系列文
Windows 桌面軟體 CI 實戰:從系統工具開發到 Zero-RDP 自動化測試錄影24
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言