昨天把 manifest 的三層結構(profile / bootstrap / chapters)定下來了,但那份 YAML 自己不會動。今天要處理讀進這份設定檔、真正操作 App、把截圖吐出來的角色:runner。
一份完整的手冊 manifest,章節數可能上看二、三十章,跑一次要十幾分鐘。我覺得,一個好的 runner 要做的不會只是照著 manifest 從頭執行到尾,也需要這兩件事:
在寫執行迴圈之前,有一個值得討論的決定:
每一章開始前,要不要重新啟動一次 App,回到乾淨狀態?
這是一個經典的取捨問題,要不要冒著狀態污染的風險,去追求更快的速度?這在自動化測試領域應該也是會被討論到的問題。
我的想法是,通常這個腳本是不會太頻繁地使用的,不需要為了省那幾分鐘而冒這麼大的風險。我想要的是可以「穩定」地自動化建立使用手冊,因此,我預設每一個章節都從乾淨狀態重新開始,要重新啟動、重新注入狀態,才開始該章節的截圖步驟。
--chapter 局部重跑有了「每章獨立開機」這個前提,局部重跑就只是把迴圈換成單一章節而已:
npm run manual # 整本重跑
npm run manual -- --chapter live-monitor # 只重跑這一章
只要設計一個可以指定章節的參數就好,上面只是其中一種範例。
失敗當下,runner 應該自動把除錯需要的東西存下來,讓人不用重跑一次就能看懂發生了什麼事:
step (step index)data-testid 清單try {
await runStep(page, chapter.steps[i], dir)
} catch (cause) {
const dir = `output/failures/${chapter.id}`
await page.screenshot({ path: `${dir}/failure.png`, fullPage: true })
await writeFile(`${dir}/failure.html`, await page.content())
throw new RunnerError({ chapter: chapter.id, stepIndex: i, step: chapter.steps[i], cause })
}
這幾樣東西最後要合成一則錯誤訊息,而這則訊息除了給人類當參考之外,主要是給 AI Agent 看的 (之後再討論)。以下就拿範例專案實際跑一次,看看這則訊息長什麼樣、又能幫我們做到什麼。
總覺得還是要有一個實際範例比較有說服力,大家也才能對這個腳本比較有畫面。因此,以下設計了一個簡單範例:用 runner 去執行整本 manifest 截圖,但遇到錯誤,接著排查出原因並進行修正,最後重新跑對應章節的腳本 (而不是整本)
範例專案的 manifest 目前有四章,照 order 排是 overview / live-monitor / layout-preset / settings。直接整本跑:

$ npm run manual -- --mode web
manifest: demo-stream-app v1.2.0 共 4 章 [web]
▶ [1/4] overview 介面總覽
✔ [1/4] overview 3 steps / 1 shot 1.3s
▶ [2/4] live-monitor 即時監控畫面
✔ [2/4] live-monitor 6 steps / 2 shots 1.7s
▶ [3/4] layout-preset 儲存版面設定
✖ [3/4] layout-preset step 4 失敗:{"action":"screenshot","name":"layout-preset-01"}
找不到 annotate[1].testid「grid-cell-fps_1」
目前畫面上有 150 個 testid,其中 grid-cell* 開頭的有:
grid-cell_1 / grid-cell-view_1 / grid-cell-det_1a / grid-cell-det_1b /
grid-cell-name_1 / grid-cell-timestamp_1 / grid-cell-remove_1 / grid-cell_2
失敗當下的畫面:output/failures/layout-preset/failure.png
失敗當下的 DOM:output/failures/layout-preset/failure.html
修好之後只要重跑這一章:npm run manual -- --chapter layout-preset
尚未執行的章節:settings
不用打開瀏覽器,這則訊息已經交代了四件事:
layout-preset 的第 4 步,那一步是 screenshot,名字叫 layout-preset-01。testid,而是 annotate[1].testid——是標註欄位出問題,不是操作出問題。grid-cell* 開頭的那一群列出來了:grid-cell_1、grid-cell-view_1、grid-cell-name_1、grid-cell-timestamp_1……都在,就是沒有 grid-cell-fps_1。settings 這章根本還沒開始跑。第 3 點是關鍵。有了候選清單,可以立刻分辨兩種完全不同的狀況:
grid-preset-sav 對上清單裡的 grid-preset-save)——那就是打錯字,把字補回去就好。這次顯然是後者。打開留下來的 failure.png 就確認了:

4×4 版面下,格子底部的資訊列只剩下攝影機名稱跟時間戳,偵測到 N 個物件 與 NN fps 那兩段不見了——因為格子寬度不夠時,這兩個元素會被整個移除(不是隱藏,是真的不在 DOM 裡)。所以「grid-cell-fps_1 在 2×2 存在」跟「在 4×4 不存在」兩件事同時都是對的,錯的是 manifest 要它在 4×4 的畫面上被標註。
候選清單這件事實作上有個小細節很值得注意:前綴要從最長的開始試。
grid-cell-fps_1找不到時,如果只用第一段grid當前綴,150 個 testid 裡光是工具列就佔滿了前八個(grid-panel/grid-toolbar/grid-layout_1x1……),最有用的grid-cell-*一個都看不到。從grid-cell-fps_1開始逐段往回退到grid-cell,列出來的才是真正該比對的那一群。
問題不在 runner,在 manifest 的步驟順序——這張圖要標 fps,就只能在 2×2 拍。把截圖那一步搬到切版面之前就好:
# 先填一格,版面才不是空的
- { action: dblclick, testid: camera-row_gate-a }
- - { action: click, testid: grid-layout_4x4 }
-
+ # grid-cell-fps_{n} 在 3×3 以上會被精簡掉,這張圖只能在 2×2 拍
- action: screenshot
name: layout-preset-01
clip: { testid: grid-view, padding: 12 }
@@
- { key: fps, testid: grid-cell-fps_1, legend: 即時影格率 }
+ - { action: click, testid: grid-layout_4x4 }
+
- { action: click, testid: grid-preset-save }
這就是 Day 14 講「可 diff」的實際樣子:改動是一個步驟的位置,加上一行說明為什麼,review 的人不需要在腦中模擬整段流程。

$ npm run manual -- --chapter layout-preset --mode web
manifest: demo-stream-app v1.2.0 只跑 1 章(--chapter layout-preset) [web]
▶ [1/1] layout-preset 儲存版面設定
✔ [1/1] layout-preset 10 steps / 2 shots 1.5s
完成 1 章,總共 2.2s -> screenshots/
2.2 秒結束,另外三章的產物完全沒有被碰到,失敗現場也一併被清掉了:
screenshots/ screenshots/
├─ overview-01.png ├─ overview-01.png
├─ live-monitor-01.png → ├─ live-monitor-01.png
└─ live-monitor-02.png ├─ live-monitor-02.png
├─ layout-preset-01.png
output/failures/ └─ layout-preset-02.png
└─ layout-preset/
├─ failure.png output/failures/
└─ failure.html └─ (空的)
這一段是真的跑出來的,不是示意。範例專案的
chore/day15分支把「還沒修」與「修好」排成兩個 commit,README 有完整的重現步驟——取回修正前的那一版 manifest、跑到失敗、再重跑那一章。
檔名規則要跟 Day 05 定下的命名規則一致 (docs/{order}-{id}.md、screenshots/{name}.png);輸出目錄結構固定,方便後續 pandoc 合併與比對。每次執行前,要不要清空舊產物,也需要一個明確的預設值——通常是清空,避免舊產物殘留造成混淆,除非明確指定要保留基準線截圖用於比對 (例如要對照改版前後的畫面差異時)。
清空的單位是一章,不是整個輸出目錄。上面那次局部重跑只清掉 layout-preset-* 這幾個檔案,這是局部重跑之所以有意義的另一半:如果每次執行都把整個目錄砍掉重建,那 --chapter 就只剩下「跑得比較快」,其他三章的產物一樣得重來一次才拿得回來。
值得一提的是,截圖是扁平地全部放在
screenshots/底下,沒有按章節分資料夾,卻仍然刪得乾淨。這是因為檔名前綴就是章節 id。這正是 Day 05 那套命名約定在發揮作用:產線各段之間靠檔名對齊,不需要額外維護一份索引檔來記錄「哪些圖屬於哪一章」。
跑一份大型 manifest 時,很自然會想:
這些章節之間互不依賴,為什麼不平行跑,加快速度?
這件事理論上是可行的,但前提是系統的資源要夠多,同時開多個瀏覽器或是 Electron 其實也會消耗不少系統資源,可能會影響系統穩定性 (e.g. 變 Lag XD),進而導致截圖可能會發生預期之外的失敗。
對使用手冊的建立來說,穩定性應該是第一優先的,因此,除非真的很確定系統資源足夠,不然我會建議還是一個一個章節執行就好。
這個取捨,恰好跟 E2E 測試的常見取捨完全相反。測試通常都會要求快、要求平行執行,好讓 CI 回饋週期短
上面那則錯誤訊息的最後兩行,是把「修好之後只要重跑這一章」的完整指令、以及「尚未執行的章節」直接印出來。這不只是貼心,更重要的是方便之後 AI agent 接手修 manifest 之後,可以直接用錯誤訊息中所提示的指令,不必自己拼湊。
到目前為止,應該可以算做 Part 1。
前面鋪陳了動機和需求,接著介紹比較基礎 無聊 的各種實作細節,這兩天開始介紹 manifest 描述「要什麼」、runner 決定「怎麼跑」,兩篇加起來才是一套完整的截圖工具。局部重跑靠的是每章獨立開機、獨立可執行;失敗時留下的截圖、DOM、testid 清單,則是讓後續 AI agent 上場時有線索可以操作。
明天開始進入 Part 2,讓 AI Agent 進場了。
今天那次失敗,是人讀著錯誤訊息把 manifest 修好的,之後就可以換 AI Agent 讀同一則訊息,讓他自己搞定。