Writing
Syncnext 的 App Debug Acceptance 實作
App Debug Acceptance 是編譯進 Debug App 的驗收入口。Agent 在獨立 Swift 檔案中準備場景、呼叫正式程式並寫斷言;App 完成初始化後,依啟動參數執行指定 case,將結果寫入同一輪 run ID 的 Log。
歷史難題:已有自動化能力,Agent 仍逐步操作 UI
appDebugCode 是我在 2023 年留下的掛點,用來在 App 初始化完成後快速重跑指定 function,驗證修改。當時 SwiftUI Preview 在我的專案裡不夠好用,部分功能也依賴 App 完成初始化,因此需要一個能在完整 App 環境中直接執行程式的位置。
後來我也整理過供 Agent 操作 App 的 Automation,但 Agent 仍常採用「截圖 → 判斷畫面 → 傳送遙控按鍵 → 再截圖」的流程。每輪驗收都要重新導航、準備設定,並從畫面確認操作進度;修復後還要重走一次。播放器 seek 的驗收也顯示,Agent 已經知道怎麼執行 seek。
這次改造把 Agent 的驗收工作單位固定為獨立 Swift case,並寫入 Skill 的執行流程:由程式準備資料與設定、呼叫正式方法、等待完成訊號並作斷言。修復後可以選擇同一個 case、帶入相同輸入重跑,再以新的 run ID 核對結果。原有掛點得以保留,個別任務則不必再共同修改 appDebugCode。
改善了什麼
| 驗收環節 | 以前:逐步截圖與遙控 | 現在:Swift case |
|---|---|---|
| 準備場景 | 透過 UI 依序切換設定、選擇來源、搜尋與進入影片;每輪重新導航到目標狀態。 | 由 case 寫入本次資料與設定,帶入指定輸入,再呼叫正式搜尋或播放流程。 |
| 執行動作 | 每一步都經過截圖、判斷畫面和焦點、傳送按鍵,再查看下一張截圖。 | 在同一段 Swift 程式中串接正式方法呼叫與非同步等待,動作之間不需要逐步交回 Agent 看圖決定。 |
| 判定結果 | 從畫面確認是否進到下一步;精確的播放器位置或內部狀態還要另外查證。 | 直接讀取實際狀態作斷言,例如 seek 後的位置、暫停狀態與播放時間是否前進,並記錄檢查名稱和結果。 |
| 修復後重跑 | 重新操作同一串選單與按鍵,再確認測試設定和畫面位置。 | 沿用同一個 case 與輸入,以新的 run ID 重跑,對照每項檢查。已編譯的 case 可直接重新啟動;改過 Swift 程式則重新建置與安裝。 |
以目前的 HTTPPlayback case 為例,驗證「暫停時 seek」會執行以下檢查:
- 經正式
PlayerHelper開片並保持暫停,等待播放器可 seek。 - 透過 App 的 HTTP 播放介面 seek 到 7.25 秒,斷言回傳狀態仍為 paused,且位置誤差小於 0.5 秒。
- 讀取實際掛載的
AVPlayer時間,等待 500 毫秒後再次讀取,斷言時間變化小於 0.1 秒,確認暫停狀態確實保留。
這段測試一次由 case 執行,失敗時可從檢查名稱區分「位置不對」與「暫停沒有保留」。修復後直接重跑相同條件;焦點、字幕和排版等畫面問題,再以實際畫面驗收。
1. 執行位置與分工
入口放在 App 原有初始化流程之後:
#if DEBUG
await appDebugCode()
#endif執行順序如下:
flowchart LR
A["App 初始化"] --> B["appDebugCode"]
B --> C["依 case ID 分派"]
C --> D["case.run(runID:)"]
D --> E["正式流程與斷言"]appDebugCode 將啟動參數交給 StudyTVAcceptanceRuntime.runRequested,由它處理 case 查找與開始/完成事件。各 case 自己準備設定和測試資料,呼叫本次要驗證的正式功能。
Xcode 工具負責建置;主機端 helper 負責裝置確認、部署、啟動與取回證據。業務斷言留在 App 的 case 裡。
2. case 協定與分派
case 的共用協定:
@MainActor
protocol StudyTVAcceptanceCase: AnyObject {
static func run(runID: UUID) async throws -> Bool
}每個 case 繼承 NSObject,並宣告唯一的 Objective-C 型別名稱:
@objc(StudyTVAcceptance_DispatcherSettingsSmoke)啟動參數傳入 DispatcherSettingsSmoke,runtime 就以 NSClassFromString("StudyTVAcceptance_" + caseID) 查找型別,再轉成 StudyTVAcceptanceCase.Type 後執行。
目前的選擇規則是:
- 一個 process 執行一個指定 case;無 case 參數時不自動執行新案例。
- case ID 僅接受 ASCII 英文字母、數字與底線;run ID 可由主機提供 UUID,省略時由 App 產生。
- 未知 case、重複選擇或無效 run ID,記錄拒絕與失敗,不改跑其他 case。
- 有效 case 在初始化後等待 3 秒,再記錄 started 並執行。這段延遲只控制啟動時機,case 仍須等待正式流程的完成訊號。
案例放在 Syncnext tvOS/!debug/Acceptance/ 目錄,透過 Xcode 的 PBXFileSystemSynchronizedRootGroup 自動收錄。新增案例只需要增加自己的 Swift 檔案,不必修改中央登錄清單。
3. 最小可執行 case
以下是現有設定 smoke case 的等價寫法,將較長的 Log 字串拆開:
#if DEBUG
import Foundation
@objc(StudyTVAcceptance_DispatcherSettingsSmoke)
final class DispatcherSettingsSmokeAcceptance:
NSObject, StudyTVAcceptanceCase
{
@MainActor
static func run(runID: UUID) async throws -> Bool {
AppSettingHelper.shared.appSearchPreTMDBEnabled = false
let actual = AppSettingHelper.shared.appSearchPreTMDBEnabled
let passed = !actual
AppLogger.shared.log(
"event=app.debug.acceptance.check "
+ "runID=\(runID.uuidString) "
+ "case=DispatcherSettingsSmoke "
+ "check=tmdb_presearch "
+ "expected=false actual=\(actual) passed=\(passed)",
category: "StudyTVAcceptance"
)
return passed
}
}
#endif這個 case 的驗收範圍是設定寫入與讀回。run 回傳斷言結果,runtime 統一輸出完成事件;case 不重複輸出開始/完成事件。
功能驗收則在同一位置呼叫正式流程。現有的 HTTPPlayback 專門驗證 App 的 HTTP 播放介面:它經 PlayerHelper 開啟影片,對照 API 回傳與實際掛載的 AVPlayer,檢查 seek 後的位置、暫停狀態及時間前進。
case 可以直接呼叫 App 裡既有的方法,包括播放器 seek。測試動作由 Swift 程式組合,啟動參數只選擇已編譯的程式與輸入。
非同步功能要等搜尋完成、播放進度前進或分析回呼等實際條件,再作斷言和回傳。只把 View 掛上去,就只能證明該畫面已呈現。
實際案例 MusicRecognitionConfirmationAcceptance 的 run 區域:讀取啟動參數、準備設定、呼叫正式播放器,並觀察辨識狀態與執行斷言。
4. 建置、啟動與讀取結果
新增或修改 Swift case 後,先建置包含該檔案的已簽章 Debug App。以下使用我的書房電視 helper,從 App repo 執行;STUDY_HELPER 指向 study_tv.py,APP_PATH 是剛建置的 App 路徑:
RUN_ID="$(uuidgen)"
python3 "$STUDY_HELPER" launch \
--app-path "$APP_PATH" \
-- --syncnext-acceptance-case=DispatcherSettingsSmoke \
"--syncnext-acceptance-run-id=$RUN_ID"
tvOS 模擬器中的驗收入口通知:啟動 DispatcherIdentitySmoke 後,App 顯示實際 case ID 與本輪 run ID。
helper 會確認書房電視身份,再安裝與帶參數啟動 App。若只是重跑、切換已編譯 case 或修改輸入,可省略 --app-path,重用裝置上的 binary。
將 RUN_DIR 設為本輪證據目錄,取回 Log:
python3 "$STUDY_HELPER" logs --output "$RUN_DIR/app.log"以下是此 smoke case 成功時的主要事件,省略 waiting 事件,UUID 與 PID 以佔位符表示:
event=app.debug.acceptance.started runID=<UUID> case=DispatcherSettingsSmoke processID=<PID>
event=app.debug.acceptance.check runID=<UUID> case=DispatcherSettingsSmoke check=tmdb_presearch expected=false actual=false passed=true
event=app.debug.acceptance.completed runID=<UUID> case=DispatcherSettingsSmoke processID=<PID> passed=true先核對同一個 case 與 run ID 的 started/completed;case 有輸出 check 時,再對照檢查結果。缺少完成事件時不能判定通過,須確認是否仍在執行或已中斷;出現失敗則保留原因。通過的範圍由 case 裡的斷言決定。
書房電視是專用驗收環境,case 可以直接修改並留下本次需要的 App 設定與測試資料。搜尋詞、來源篩選等前置條件也由程式準備。
5. 接入除錯流程
我的除錯流程分成三個階段:
- 確認問題並取證:用 case 重現指定來源、資料及設定,記錄失敗狀態與分析需要的證據。
- 修復:依證據修改正式功能所屬的模組。
- 驗收:保留原本的問題條件與判定標準,以新的 run ID 重跑,核對這一輪的斷言及完成事件。
同一個掛點可以用於重現和驗收,但每一輪要先確定目標。確認某條路徑會失敗,和確認修復符合預期,是不同的檢查。
涉及焦點、字幕或首幀等畫面問題時,由 case 準備場景並掛載正式畫面,再補截圖與必要的遙控操作。