前六天的「產線實作」陸續把定位元件、狀態注入、時機與構圖、標註、標號、遮蔽這些單點技術都做出來了,但它們現在還是散落在各自的腳本裡。接下來要把產線組裝起來,這件事分兩步:先用一份設定檔把這些技術收攏起來,再靠 runner 真正執行它,今天先處理第一步。
這份設定檔就是整條產線的心臟。
如果是直接請 AI 寫一支 TypeScript 腳本,從啟動 App、操作、截圖到輸出一手包辦,看起來確實很方便。但是,這個腳本基本上會是一個上百行的程式碼,每次有版本更新的時候,AI 給你的往往是一份幾乎全新的版本 (或許脈絡一致,但很難一眼看出),而不是針對變動處的小修改。
結果就是 diff 整片都是紅綠、沒有人 review 得動,最後大家只能賭它是對的,沒有人真的知道這次重新生成到底改了什麼。

(我知道這件事不是不可能,只是有點辛苦XD)
這就是「宣告式」的意思:前面那支 TypeScript 腳本是程序式的,一行一行寫「怎麼做」。設定檔則是純資料,只描述「要什麼」 (e.g. 要等哪個元件、截哪一塊、標哪幾個框),本身不會執行任何動作,怎麼跑、跑幾次都交給明天的 runner 決定。
改成宣告式的設定檔之後,有四個關鍵性質:
git diff 一眼就看得出這次加了什麼。整份設定檔分成三層:
steps。前兩層寫在 manifest/manual.yaml:
profile: demo-stream-app
version: 'v1.2.0'
locale: zh-Hant
bootstrap:
viewport: { width: 1600, height: 900, deviceScaleFactor: 2 }
# 狀態注入在第一次 navigation 之前發生(Day 09)
storage:
locale: zh-Hant
role: operator
layout: '{"mode":"2x2","cells":[null,null,null,null]}'
clock: '2025-09-01T09:00:00'
disableAnimations: true
章節則是一章一個檔案 (manifest/{order}-{id}.yaml),而不是全部塞在同一份陣列裡。這樣做是為了讓 diff 跟重跑的單位都是「一章」,新增章節不會動到別人的檔案,三個人同時改三章也不會撞在一起。
前兩天為了聚焦在單一步驟,設定檔片段都是用 JSON 寫的,完整的 manifest 則是 YAML。原因很單純:YAML 可以寫註解,而註解正是「為什麼這一步要等這個元件」這種資訊的家。
以範例 App 的「即時監控」這一章為例:
id: live-monitor
title: 即時監控畫面
order: 20
steps:
# 等骨架屏消失、清單出現才開始(Day 10)
- { action: waitFor, testid: camera-list-skeleton, state: detached }
- { action: waitFor, testid: camera-list }
- action: screenshot
name: live-monitor-01
clip: { testid: monitor-page, padding: 12 }
annotate:
- { key: search, testid: camera-search, legend: 攝影機搜尋框 }
- { key: layout, testid: grid-layout-group, legend: 版面切換 }
- { key: save, testid: grid-preset-save, legend: 儲存目前版面 }
- { action: fill, testid: camera-search, text: lobby }
- { action: dblclick, testid: camera-row_lobby-01 }
- action: screenshot
name: live-monitor-02
clip: { testid: grid-panel, padding: 12 }
annotate:
- { key: cell, testid: grid-cell_1, legend: 剛填入的格子 }
- { key: remove, testid: grid-cell-remove_1, legend: 清除此格 }
這份 YAML 丟給 runner,跑出來的就是這兩張圖:


前面六天的東西在這裡全部就定位了:clock 凍結了時間戳、disableAnimations 停掉了偵測框的飄移、waitFor ... detached 等掉了骨架屏、clip 決定構圖、annotate 畫框標號、legend 之後會渲染成圖片下方的表格。
testid,不收任意 selector看起來少了彈性,但這是刻意的:selector 一旦開放成自由字串,AI 就會開始寫 .camera-list > div:nth-child(3) 這種東西,Day 08 好不容易建立的穩定性就白費了。限制成 testid 之後,驗證時還可以直接拿 TESTID.md 對照,這是自由字串做不到的。
這是整份設計裡比較反直覺的一條。工程師的本能是「加個 if、加個 for 不是很方便嗎」,但一旦有了這些,人 review 的時候不再是看過 diff 就懂全貌,而是要在腦中模擬所有分支。
order 獨立於檔案順序章節順序由 order 欄位決定,用 10 的倍數編號,中間留白。之後要在第 20 章跟第 30 章之間插一章,直接用 25,不需要動到其他任何檔案。
設定檔的價值有一半來自「它可以被機器檢查」。驗證分兩個階段,第一個階段在 App 啟動之前就跑完:
Error: manifest 驗證失敗(2 個問題):
- live-monitor.steps[1]: 不存在的 action「type」,可用的有:click / dblclick / fill / waitFor / wait / scroll / hover / dismiss / screenshot
- live-monitor.steps[2]: action: screenshot 缺少必填欄位 name
這種錯誤根本不需要打開瀏覽器就知道,所以也不該等到產線跑到第 18 章才爆掉。
第二個階段是執行期,這時候的錯誤訊息是寫給 AI agent 看的,所以不能只說「找不到元素」:
[live-monitor] step 1 失敗:{"action":"fill","testid":"camera-searchbox","text":"lobby"}
找不到 testid「camera-searchbox」
目前畫面上有 119 個 testid,其中 camera* 開頭的有:
camera-panel / camera-panel-title / camera-add / camera-search / camera-hint / camera-list / camera-row_gate-a / camera-state_gate-a
失敗當下的畫面:output/day14/failure.png
把候選清單跟失敗當下的畫面一起吐出來,agent 才有辦法自己把 camera-searchbox 修成 camera-search,不然它就只能重猜一次。這件事在後面提到讓 AI 寫設定檔的時候變得非常關鍵。
YAML 的自動型別推斷很方便,但也很會惹事:
no、yes、on、off 在 YAML 1.1 下會被解析成布林值,本意是字串的話一定要加引號。1.20 沒加引號會變成數字 1.2,尾端的零直接消失。說明: 這是範例),會被誤判成 key-value。因此,任何看起來像數字、布林值,或含有特殊符號的字串,一律加引號,避免不必要的麻煩。
動詞集要小,但小不等於每個動詞的行為都一樣嚴格。dismiss (關掉可能殘留的對話框) 就是一個天生允許找不到目標的動詞——上一章結束時對話框不一定還開著,硬要找到才繼續反而會卡住整條產線。
這跟 Day 13 的 redact 剛好是一對:同樣是「找不到目標」,dismiss 該跳過,redact 該中止。容錯策略是動詞語意的一部分,不是全域設定,所以它應該寫死在動詞的定義裡,而不是開一個 optional: true 讓每個人自己填——那個欄位一旦存在,遲早有人會加在 redact 上。
Day 08 說 data-testid 是手冊產線與 E2E 測試的共同契約,manifest 出現之後,這份契約多了一個真實的引用者。testid 改名時如果沒有同步更新 manifest,下次重跑就會在那一步停下來。
好消息是,這個失敗是立刻可見的:驗證會直接指著出問題的那一步,而不是默默產出一張怪怪的圖。這也是為什麼 validate 值得放進 CI——它等於幫 testid 這份契約補上了一個守門員。
今天這份設定檔自己不會動,它做的事情是把前六天的技術「說清楚要用在哪一步」,真正讓它動起來的是明天的 runner。
設定檔負責描述,runner 負責執行,兩篇加起來才是一套完整的截圖工具。
不過這裡的「完整」有兩點要先講清楚:
明天要處理執行這份設定檔的 runner:怎麼做到局部重跑、以及失敗的時候該留下哪些資訊。