錄影告訴你「過程」。今天講另外兩份產物:截圖回答「那一刻」,
普查回答「桌面上多了什麼、是誰開的」。
兩者各帶一個教訓,而那兩個教訓是同一件事的兩張臉: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
如果你的診斷工具沒有被測試,你其實不知道它有沒有在工作——
而你會在最需要它的那一天發現。
影片給你直覺,但它沒辦法搜尋、沒辦法比對、也沒辦法精確回答
「那個視窗是哪個處理程序開的」。
「普查」就是把當下桌面上所有頂層視窗列一遍:
WindowSnapshot(hwnd=1, title="Some Other Dialog", class_name="#32770",
pid=100, is_visible=True)
相關原始碼:
src/wintegrate/diagnostics.py#L346-L402(WindowSnapshot、CensusDiff、WindowCensus三個放在一起)
五個欄位,每個都有用途:
hwnd——視窗的身分證,用來 difftitle——人類看得懂的線索class_name——比標題可靠得多的識別(第四章會反覆看到這件事)pid——這是關鍵。 知道是誰開的,才知道要問誰is_visible——不可見的視窗多如牛毛,過濾掉才看得到重點pid 的價值在於:當一個對話框沒有出現,你要問的是「是誰的對話框」。
沒有 pid 的視窗清單只能告訴你「有一個標題叫 XXX 的東西」,那不足以定位。
(這一欄後面會救我一次。一個應該屬於被測應用程式的錯誤對話框,
正是靠 pid 才確認了它真的是那個應用程式自己彈的。)
一台 Windows 隨時有幾十上百個頂層視窗,單獨一份清單你不會想讀。有價值的是差異:
before = WindowCensus.capture()
launch_app()
after = WindowCensus.capture()
diff = WindowCensus.diff(before, after)
# diff.added ← 這段期間新出現的視窗
diff.added 直接回答「我按下去之後,桌面上多了什麼」。這同時是兩件事的基礎:
第二點就是「誰搶走了焦點」的答案。測試逾時了,打開 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_app → window_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.diff 的 added |
| 每一步花了多久 | 事件時間軸,時間戳是 float 因為要相減 |
明天講一件更基本的事:什麼時候該停止推理,去看這些證據。