前兩天分別處理了「找得到元件」跟「畫面內容穩定」,今天要處理截圖那個瞬間本身:什麼時候截、以及要截多大的範圍。
今天開始,程式碼都會直接對著 Day 06 介紹的範例 App (auto-manual-gen 裡的 DemoStreamApp) 來寫,selector 用的都是它實際的 data-testid,想跟著跑的話 clone 下來就可以了。
對於等待截圖時機的策略,可以分成 2 個等級:
等 UI 元件 (比較好)
如果要用精確一點的描述,應該是「等語意訊號」,也就是我們人類在操作應用程式時,會使用的那套邏輯,例如:等某個按鈕出現、等載入中的圖示 (loading spinner) 消失。
範例 App 的攝影機清單正好就是這種情境:清單刻意做成非同步載入,載入期間顯示骨架屏 (camera-list-skeleton),載入完成之後才換成真正的清單 (camera-list)。要等的就是這個交棒的瞬間:
// 骨架屏消失、清單出現,才按快門
await page.getByTestId('camera-list-skeleton').waitFor({ state: 'detached' })
await page.getByTestId('camera-list').waitFor({ state: 'visible' })
await page.screenshot({ path: 'screenshots/overview-01.png' })
這裡等的是 detached 而不是 hidden,因為範例 App 一律用 v-if 做條件渲染,元件不存在就是真的從 DOM 裡消失,而不是被隱藏起來 (這點 Day 08 有提過)。

等固定時間
直接等待一段固定的毫秒數,例如「操作完之後等 500ms 再截圖」。這是最後手段,只有在第一種做不到 (例如動畫效果無法用語意訊號判斷結束) 的情況下才用,而且要把這個固定等待時間壓到最小,避免整條產線因為到處都是保守的固定延遲而變得太慢。
await page.waitForTimeout(500)
範例 App 的骨架屏刻意設定成「至少顯示 450ms」,就是為了凸顯這個等級的問題:固定等 300ms 會拍到骨架屏,固定等 1000ms 又是白白多花的時間,而且換一台比較慢的機器 (e.g. CI) 之後,這個數字還要重猜一次。

其實還有一種,叫做
networkidle,也就是等網路請求停下來 (Playwright 的定義是「連續 500 ms 沒有新的網路連線」)。雖然看起來是一個介於上面兩種策略之間的一個折衷辦法,但是 Playwright 官方文件已經把這個值標記為DISCOURAGED。原因是它太容易永遠等不到:長輪詢 (long polling)、WebSocket、SSE 這類連線本質上就是持續在背景保持活躍。而且,很多後台背景還一直在送 analytics,最終導致永遠湊不滿那停止網路請求 500 ms 的要求。反過來說,它也有可能會太早滿足。範例 App 啟動時會打一支
./api/cameras,這支請求在一般情況下會直接失敗、退回內建的假資料,網路幾乎立刻就安靜下來了;但骨架屏還要再站滿 450ms 才會換成清單。這時候networkidle早就通過了,拍到的仍然是骨架屏。
講完截圖時機,再來就是截圖範圍。
雖然平時講「截圖」就是指是整頁 (或整個應用程式) 一起截圖,但實際上在使用手冊中,為了避免畫面太小看不清楚,有時候會只擷取某一部分。因此,依據截圖範圍,可以分成三種構圖:
整頁截圖
就是最常見的做法,拍下整個可見畫面,適合需要展示整體版面配置的場合。
// 預設只拍目前看得到的畫面 (viewport)
await page.screenshot({ path: 'screenshots/overview-01.png' })
// 加上 fullPage 才會把捲動範圍一起拍進去
await page.screenshot({ path: 'screenshots/overview-01.png', fullPage: true })

元素截圖 (locator.screenshot())
針對單一元件截圖,Playwright 會自動算出這個元件的邊界。例如只拍攝影機設定對話框:
await page.getByTestId('camera-edit_gate-a').click()
await page.getByTestId('camera-dialog').waitFor({ state: 'visible' })
await page.getByTestId('camera-dialog').screenshot({
path: 'screenshots/camera-02.png',
})

區域裁切 (screenshot({ clip }))
手動指定一個矩形範圍截圖。同樣拍那個對話框,但四周多留一點空間,讓讀者看得到它是疊在哪個畫面之上:
const dialog = page.getByTestId('camera-dialog')
const box = await dialog.boundingBox()
const padding = 24
await page.screenshot({
path: 'screenshots/camera-02.png',
clip: {
x: box.x - padding,
y: box.y - padding,
width: box.width + padding * 2,
height: box.height + padding * 2,
},
})

通常使用手冊的截圖大多是第三種,會擷取某個元件再加上周圍一點點範圍 (幫助讀者知道元件位置)。
實際對著範例 App 跑過一輪之後,有幾個當下會愣住、但講破了很簡單的狀況,這邊一起分享。
頁面切換、下拉選單展開這類 UI 動畫,如果快門剛好按在動畫進行中,就會拍到一張糊掉的中間畫面。範例 App 的偵測框 (grid-cell-det_1a) 更誇張,它的 CSS 動畫是持續飄移的,根本沒有「動畫結束」這個時間點可以等。
與其用固定等待去賭動畫跑完了沒,不如直接把動畫關掉。範例 App 有留一個 .no-motion 的 class 當 hook,掛到 <html> 上就會停掉所有動畫與轉場:
await page.evaluate(() => document.documentElement.classList.add('no-motion'))
如果是不能改的 App,就自己注入一段 CSS 蓋過去:
await page.addStyleTag({
content: `
*, *::before, *::after {
animation-duration: 0s !important;
transition-duration: 0s !important;
}
`,
})
這個坑是 Day 09 跟今天交界的地方。page.clock.install() 連 setTimeout / setInterval 都一起接管了,而且凍結之後時間不會自己往前走。範例 App 的骨架屏是用 setTimeout 排程的,時間不走,它就永遠不會消失,等待直接 timeout —— 明明兩天的程式碼分開看都沒問題,合在一起卻卡住。
兩種解法,看需求挑一個:
// 只需要固定畫面上的時間顯示,讓 timer 照常跑
await page.clock.setFixedTime(new Date('2025-01-01T09:00:00'))
// 或是完全接管時間,但在等待之前自己把時間往前推
await page.clock.install({ time: new Date('2025-01-01T09:00:00') })
await page.clock.runFor(500)
範例 App 的 Toast (toast) 3 秒後會自動消失。這種元件對固定延遲特別不友善:等太短還沒出現,等太長已經消失了,而且兩邊的邊界都會隨機器效能移動。
做法是等它出現的當下立刻截圖,中間不要再插入任何其他等待:
await page.getByTestId('grid-preset-save').click()
await page.getByTestId('preset-dialog-name').fill('大廳巡檢')
await page.getByTestId('preset-dialog-confirm').click()
// 等到就立刻拍,不要在這中間做別的事
await page.getByTestId('toast').waitFor({ state: 'visible' })
await page.screenshot({ path: 'screenshots/preset-03.png' })
如果實在拍不穩,比較乾淨的做法是讓 App 把「Toast 顯示時間」開成可注入的設定,產線跑的時候調長一點,而不是在腳本裡跟它賽跑。
boundingBox() 要重新取一次boundingBox() 回傳的座標是相對於整個頁面,而且拿到的是「呼叫當下」的快照。範例 App 的攝影機清單有 15 台、長到需要捲動,如果先取了座標、之後才把目標捲進畫面,那組座標就過期了,拿去當 clip 會裁到旁邊去。
正確的順序是先捲動,再取座標:
const target = page.getByTestId('camera-row_fence-west')
await target.scrollIntoViewIfNeeded()
const box = await target.boundingBox() // 捲動完成之後才取
順帶一提,長清單通常也不需要整條拍進去,只拍前面幾列、再於圖說裡註明「以下省略」就夠了,不然一張圖縮到手冊裡反而看不清楚。
到這邊,一張時機正確、範圍也正確的截圖就有了。明天開始要在這張截圖上動手,用 CSS 疊層畫框與標號,而不是用傳統的影像處理方式!