iT邦幫忙

2026 iThome 鐵人賽

DAY 4
0
Software Development

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

Day 4:「成功回傳」不等於「拿到有用的東西」

  • 分享至 

  • xImage
  •  

錄影告訴你「過程」。今天講另外兩份產物:截圖回答「那一刻」,
普查回答「桌面上多了什麼、是誰開的」。

兩者各帶一個教訓,而那兩個教訓是同一件事的兩張臉:
PrintWindow回傳成功,然後給你一張純黑的圖;
一份完整的視窗清單會列出上百個視窗,然後什麼問題都答不了。


兩種截圖,回答兩種問題

全螢幕截圖回答「當時整個畫面上有什麼」。這是找兇手用的——
那個搶走焦點的通知、那個沒人預期的更新提示,都只會出現在全螢幕畫面上。

單一視窗截圖回答「我要測的那個東西長什麼樣」。

capture_screen_image(all_monitors=True)   # 整個虛擬桌面
capture_window_image(hwnd)                # 單一視窗

相關原始碼:src/wintegrate/diagnostics.py#L68-L163,虛擬桌面索引在 src/wintegrate/interop.py#L180-L183

多螢幕不是加分題

GetSystemMetrics(0)(1) 給的是主螢幕的大小。如果 runner 或開發機
接了第二個螢幕,而你要找的對話框跳在第二個螢幕上,主螢幕截圖會完美地拍到
一片正常的畫面,然後你會得出「畫面上什麼都沒有」的錯誤結論。

虛擬桌面(涵蓋所有螢幕的那個大矩形)要用另一組索引:

SM_XVIRTUALSCREEN  = 76
SM_YVIRTUALSCREEN  = 77
SM_CXVIRTUALSCREEN = 78
SM_CYVIRTUALSCREEN = 79

注意 X/Y 可以是負數——第二個螢幕在主螢幕左邊時,虛擬桌面的原點就是負的。
直接假設從 (0,0) 開始的程式碼,會在這種配置下拍到一半黑畫面。

(這組索引後面還會再用到一次,而且是為了另一個問題:判斷一個視窗有沒有
整個跑到螢幕外。那時候「負座標不代表螢幕外」這件事會變得很重要。)

失敗截圖預設就走全螢幕,理由寫在程式碼裡:

# all_monitors: the window under test is not always on the primary
# display, and a primary-only capture of a failure elsewhere is worse
# than none — it looks like evidence.
self.capture_screenshot("failure_screenshot", all_monitors=True)

PrintWindow:拍到被蓋住的部分

裁切全螢幕畫面是最直覺的單視窗截圖做法。問題是
如果有東西蓋在上面,你切下來的就是那個東西

而在 CI 上,蓋住你的視窗的那個東西,往往正是讓測試失敗的兇手。
你想要「我的視窗長什麼樣」,拿到的是「那個彈窗長什麼樣」。

PrintWindow 走另一條路:叫視窗自己把自己畫一遍到你給的 device context 上,
不經過螢幕,所以遮擋不影響結果。

PW_RENDERFULLCONTENT = 0x00000002
ok = user32.PrintWindow(hwnd, hdc_mem, PW_RENDERFULLCONTENT)

PW_RENDERFULLCONTENT 是後來加的旗標,用來處理 DirectComposition/DWM 那類
不是用傳統 GDI 畫的視窗。沒有它,很多現代視窗會回傳空白。

然後你會拿到全黑的圖

有了旗標也不保證成功。某些 DWM/XAML 視窗就是不配合 PrintWindow
回傳一張純黑的點陣圖,而且函式回傳成功

一張全黑的截圖,比沒有截圖更糟——因為它看起來像證據。

你打開 artifacts,看到 window.png,點開是全黑。第一個念頭會是
「畫面當時就是黑的?螢幕沒亮?渲染掛了?」——你開始追一個不存在的 bug。
真相只是這個 API 對這種視窗不管用。

    PrintWindow returns an all-black bitmap for some DWM-composited and XAML
    windows, so the result is checked and falls back to cropping the desktop.
    Returning a black rectangle would be the worst outcome: an artifact that looks
    like a capture and shows nothing.
    """
    ...
    ok = user32.PrintWindow(hwnd, hdc_mem, PW_RENDERFULLCONTENT)
    img = _dib_to_image(hdc_mem, hbmp, w, h) if ok else None
    ...
    if img is not None and not _looks_blank(img):
        return img
    # 走到這裡就退回裁切桌面

條件是 ok 而且結果不是空白——兩個都要檢查。
PrintWindow 回傳成功只代表它願意畫,不代表畫出了東西。

_looks_blank 只有一行:

def _looks_blank(img) -> bool:
    """True when the image has no non-black pixel at all."""
    # getbbox() bounds the non-zero region and returns None for an all-black image.
    return img.getbbox() is None

不需要多聰明。重點不在演算法,在有沒有人去檢查。

而診斷工具壞了誰會發現?CI 上沒人會去點開那張圖。所以讓測試自己驗,
四張樣本圖涵蓋四條程式碼路徑,存進 artifacts:

def assert_has_content(img, label: str):
    """An image with no non-black pixel is a failed capture wearing a costume."""
    assert img.size[0] > 0 and img.size[1] > 0, f"{label}: empty image"
    assert img.getbbox() is not None, f"{label}: image is entirely black"

相關原始碼:tests/test_screenshots.py#L61-L65,失敗截圖那段在 src/wintegrate/session.py#L486-L493

如果你的診斷工具沒有被測試,你其實不知道它有沒有在工作——
而你會在最需要它的那一天發現。


視窗普查:影片沒辦法 diff

影片給你直覺,但它沒辦法搜尋、沒辦法比對、也沒辦法精確回答
「那個視窗是哪個處理程序開的」。

「普查」就是把當下桌面上所有頂層視窗列一遍:

WindowSnapshot(hwnd=1, title="Some Other Dialog", class_name="#32770",
               pid=100, is_visible=True)

相關原始碼:src/wintegrate/diagnostics.py#L346-L402WindowSnapshotCensusDiffWindowCensus 三個放在一起)

五個欄位,每個都有用途:

  • hwnd——視窗的身分證,用來 diff
  • title——人類看得懂的線索
  • class_name——比標題可靠得多的識別(第四章會反覆看到這件事)
  • pid——這是關鍵。 知道是誰開的,才知道要問誰
  • is_visible——不可見的視窗多如牛毛,過濾掉才看得到重點

pid 的價值在於:當一個對話框沒有出現,你要問的是「是誰的對話框」。
沒有 pid 的視窗清單只能告訴你「有一個標題叫 XXX 的東西」,那不足以定位。

(這一欄後面會救我一次。一個應該屬於被測應用程式的錯誤對話框,
正是靠 pid 才確認了它真的是那個應用程式自己彈的。)

真正有用的是 diff,不是快照

一台 Windows 隨時有幾十上百個頂層視窗,單獨一份清單你不會想讀。有價值的是差異:

before = WindowCensus.capture()
launch_app()
after = WindowCensus.capture()
diff = WindowCensus.diff(before, after)
# diff.added ← 這段期間新出現的視窗

diff.added 直接回答「我按下去之後,桌面上多了什麼」。這同時是兩件事的基礎:

  1. 找到我剛啟動的那個視窗——比用標題比對可靠太多
  2. 找到那個不請自來的東西——系統通知、更新提示、憑證對話框

第二點就是「誰搶走了焦點」的答案。測試逾時了,打開 window_census.json
看到 diff.added 裡多了一個你沒有開的視窗,案子就破了。

事件時間軸:毫秒級的因果

第三份產物是事件流水帳,從 CI 的 session_events.json 直接取出:

{"timestamp": 1788137135.5293717, "type": "launch_app",
 "message": "Launching ['notepad.exe']"}
{"timestamp": 1788137135.636827,  "type": "window_discovered",
 "message": "Window 'Untitled - Notepad' (HWND: 1048594, PID: 8388)"}

時間戳是完整的 float 秒,不是格式化過的字串——因為它的用途是相減

把同一份資料在兩種架構上對照,就得到這個系列反覆出現的那個數字:

launch_appwindow_discovered
x64 0.10 秒
ARM64 17.18 秒

同一份程式碼、同一個記事本、同一個測試。而這個 170 倍的差距在影片裡
只是「開得比較慢」,在事件時間軸裡是一個可以寫進 issue、可以拿去調逾時值、
可以拿來說服人的數字


四份產物的分工

檔案 回答的問題
session_recording.mp4 過程看起來怎麼樣?卡在哪一步?
window_census.json 桌面上多了什麼不該有的東西?是誰開的?
session_events.json 每一步花了多久?順序對嗎?
failure_screenshot.png 出事的那一瞬間,畫面是什麼樣子?

四個檔案,四個角度,刻意不重疊。每一份都要能獨立回答一個你會真的問出口的問題
否則它只是雜訊。

最後一哩:讓人看得到

我原本想把錄影嵌在 README 最上面,用 <video> 標籤指向 repo 裡的 mp4。
結果是空白——GitHub 的 markdown 過濾會把 <video> 標籤整個拿掉

gh api -X POST /markdown -f mode=gfm -f context=mangokingTW/wintegrate \
  -f text='<video src="https://.../demo.mp4" controls></video>'
# → <p dir="auto"></p>

context 這個參數不能省。少了它,同一段輸入會原封不動地把 <video> 吐回來——
過濾器的嚴格程度取決於「這段 markdown 屬於哪個 repo」,而 README 走的正是有 context 的那條路。

能內嵌播放器的只有 https://github.com/user-attachments/assets/<uuid> 這種網址,
而那必須從網頁介面拖曳上傳才拿得到,API 產不出來。

最後的做法是把完整的 run 轉成 8 倍速的縮時 GIF(涵蓋全程,不是剪輯),
GIF 會被渲染成 data-animated-image,可以內嵌自動播放。原始 mp4 留在 artifacts 裡。

會特別提這件事,是因為它符合這一章的主題:證據要放在人真的會看的地方。
一份沒人打開的 artifact,和沒有那份 artifact 是一樣的。


小結

情境 用什麼
找出是誰搶了焦點 全螢幕,all_monitors=True
我的視窗長什麼樣(可能被蓋住) PrintWindow + PW_RENDERFULLCONTENT
PrintWindow 回黑圖 檢查並退回裁切
桌面上多了什麼 WindowCensus.diffadded
每一步花了多久 事件時間軸,時間戳是 float 因為要相減

明天講一件更基本的事:什麼時候該停止推理,去看這些證據。


上一篇
Day 3:測試崩潰永遠在最後一秒:PyAV 串流編碼與 CI 錄影的三道防線
系列文
Windows 桌面軟體 CI 實戰:從系統工具開發到 Zero-RDP 自動化測試錄影4
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言