iT邦幫忙

2026 iThome 鐵人賽

DAY 10
0
AI 自動化

用 AI Agent 打造你的產品使用手冊產線系列 第 10

[Day 10] 產線實作 3:截圖時機與範圍

  • 分享至 

  • xImage
  •  

前兩天分別處理了「找得到元件」跟「畫面內容穩定」,今天要處理截圖那個瞬間本身:什麼時候截、以及要截多大的範圍。

今天開始,程式碼都會直接對著 Day 06 介紹的範例 App (auto-manual-gen 裡的 DemoStreamApp) 來寫,selector 用的都是它實際的 data-testid,想跟著跑的話 clone 下來就可以了。

時機

對於等待截圖時機的策略,可以分成 2 個等級:

  1. 等 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 有提過)。

    等骨架屏消失、清單出現才截圖

  2. 等固定時間

    直接等待一段固定的毫秒數,例如「操作完之後等 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 早就通過了,拍到的仍然是骨架屏。

構圖

講完截圖時機,再來就是截圖範圍。

雖然平時講「截圖」就是指是整頁 (或整個應用程式) 一起截圖,但實際上在使用手冊中,為了避免畫面太小看不清楚,有時候會只擷取某一部分。因此,依據截圖範圍,可以分成三種構圖:

  1. 整頁截圖

    就是最常見的做法,拍下整個可見畫面,適合需要展示整體版面配置的場合。

    // 預設只拍目前看得到的畫面 (viewport)
    await page.screenshot({ path: 'screenshots/overview-01.png' })
    
    // 加上 fullPage 才會把捲動範圍一起拍進去
    await page.screenshot({ path: 'screenshots/overview-01.png', fullPage: true })
    

    整頁截圖

  2. 元素截圖 (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',
    })
    

    元素截圖

  3. 區域裁切 (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,
      },
    })
    

    區域裁切,四周多留 padding

通常使用手冊的截圖大多是第三種,會擷取某個元件再加上周圍一點點範圍 (幫助讀者知道元件位置)。

經驗分享

實際對著範例 App 跑過一輪之後,有幾個當下會愣住、但講破了很簡單的狀況,這邊一起分享。

1. 動畫還在跑的時候按快門,會拍到過渡狀態

頁面切換、下拉選單展開這類 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;
    }
  `,
})

2. 凍結時間之後,「等骨架屏消失」會永遠等不到

這個坑是 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)

3. Toast 只活 3 秒,等太久就拍到空氣

範例 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 顯示時間」開成可注入的設定,產線跑的時候調長一點,而不是在腳本裡跟它賽跑。

4. 捲動之後,boundingBox() 要重新取一次

boundingBox() 回傳的座標是相對於整個頁面,而且拿到的是「呼叫當下」的快照。範例 App 的攝影機清單有 15 台、長到需要捲動,如果先取了座標、之後才把目標捲進畫面,那組座標就過期了,拿去當 clip 會裁到旁邊去。

正確的順序是先捲動,再取座標:

const target = page.getByTestId('camera-row_fence-west')

await target.scrollIntoViewIfNeeded()
const box = await target.boundingBox() // 捲動完成之後才取

順帶一提,長清單通常也不需要整條拍進去,只拍前面幾列、再於圖說裡註明「以下省略」就夠了,不然一張圖縮到手冊裡反而看不清楚。

到這邊,一張時機正確、範圍也正確的截圖就有了。明天開始要在這張截圖上動手,用 CSS 疊層畫框與標號,而不是用傳統的影像處理方式!


上一篇
[Day 09] 產線實作 2:用注入狀態精準控制畫面
下一篇
[Day 11] 產線實作 4:用 CSS 畫框與標註
系列文
用 AI Agent 打造你的產品使用手冊產線17
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言