visionA/local-agent/visiona-agent/startup_pipeline_test.go
jim800121chen 3f0175f1a9 feat(local-agent): Phase 0.5 visionA Agent — Wails 桌面 + tunnel client + 配對 UI
從 local-tool 複製出獨立的「visionA Agent」桌面應用(A3 純橋樑:
tunnel client + 配對 UI + 設定,不開 HTTP port、不做本機裝置/推論 UI)。
Bundle ID 與 local-tool 不同(com.innovedus.visiona-agent vs visiona-local),
雙 app 可共存。fork 後不主動 sync,需要時手動 cherry-pick。

Backend / Wails Go(AB1-AB13):
- internal/tunnel:6 狀態機(Idle/Connecting/Connected/Reconnecting/Failed/Stopped)
  + Pair/Unpair/Reconnect/Disconnect binding + ClientHooks event
- internal/auth:encrypted file token store(AES-GCM + scrypt + machineID
  fallback salt + 13 tests)
- internal/config:YAML validation + atomic write + 11 tests
- internal/log:ring buffer + ExportLog 升級 zip
- visionA-backend /api/pairing/exchange:SessionTokenStore + 17 new tests
- 三平台 build 驗證(macOS DMG 160 MB / Windows EXE / Linux AppImage)
- end-to-end 5 milestone 全綠(pairing → tunnel → forward → reuse 防護
  → tunnel drop failover)

Frontend / Next.js(AF1-AF7,沿用 visionA-frontend 基礎):
- AppShell + Header + TabNav(StatusView / PairView / SettingsView 三 tab)
- ConnectionStatusBadge 5 種狀態
- TokenInput regex 驗證 + 7 種錯誤 + 0.5s auto-switch 到狀態頁
- 設定頁 4 區塊(含重新配對 AlertDialog)
- agent-api.ts 封裝 Wails bindings(mock/real 雙實作)+ 90 tests

Phase 0.7 review-driven fix(Round 2):
- A1 Session fixation 防護(RotateSessionID)
- A3 mock pairing 預設改 false(必須明確 opt-in)+ startup log
- A4 Pair 失敗後 state 清理矩陣(exchange/Save/Start fail 各自終態)
- A5 Pair/Unpair/Reconnect lifecycleMu + 50 goroutine race test
- F1 重新配對次按鈕 / F2 PairView Esc cancel / F3 Wails BrowserOpenURL
  / F4 Settings draft 持久 + 未儲存 badge

驗證:agent backend go test -race -count=3 ./... 4 packages 全綠 /
agent frontend pnpm test 119 tests 全綠

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 11:22:01 +08:00

586 lines
18 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
// startup_pipeline_test.go — M8-4b StartupPipeline 單元測試
//
// 涵蓋情境:
// 1. CompleteStage(n) 正確切換內部 state 並進入下一階段
// 2. SkipStage(n) 把 stage 標記為 skipped 並進入下一階段
// 3. FailStage 把 pipeline 切到 failed
// 4. Soft timeout單一階段 > 20 s→ softTimeoutEmitted flag 被設
// 5. Hard timeout總時 > 60 s→ pipeline 進 failed
// 6. AutoOpenBrowser=false 時階段 5 skip + 階段 6 不檢查 timeout
// 7. RestartStartupSequence 5 步驟(不實際呼叫 ctrl.Start因為會跑真的 server
// 8. removeSentinelFile / sentinel file 偵測機制
//
// 測試使用 newTestApp()(見 server_control_test.go所有 ctx == nil
// emit 路徑會 short-circuit 但內部 state 仍會更新。
import (
"context"
"errors"
"os"
"path/filepath"
"testing"
"time"
)
// newPipelineTestApp 建一個 App 並設好 dataDir + prefs測試用
// 不啟動 watcher除非測試自己呼叫 Start
func newPipelineTestApp(t *testing.T) (*App, string) {
t.Helper()
a := newTestApp()
dir, err := os.MkdirTemp("", "visiona-agent-pipeline-test-*")
if err != nil {
t.Fatalf("mkdtemp: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(dir) })
a.dataDir = dir
a.prefs = DefaultPreferences() // macOS/Windows: AutoOpenBrowser=true
return a, dir
}
// -----------------------------------------------------------------------
// CompleteStage / SkipStage / FailStage 基本行為
// -----------------------------------------------------------------------
func TestStartupPipeline_CompleteStage_AdvancesToNext(t *testing.T) {
a, _ := newPipelineTestApp(t)
p := NewStartupPipeline(a)
// 不啟動 watcher手動設 current=1 running 模擬 Start()
p.startedAt = time.Now()
p.current = 1
p.stages[1].status = "running"
p.stages[1].startedAt = time.Now()
p.CompleteStage(1)
if p.stages[1].status != "completed" {
t.Fatalf("stage 1 status=%q, want completed", p.stages[1].status)
}
if p.current != 2 {
t.Fatalf("after CompleteStage(1), current=%d, want 2", p.current)
}
if p.stages[2].status != "running" {
t.Fatalf("stage 2 status=%q, want running", p.stages[2].status)
}
}
func TestStartupPipeline_CompleteStage_OutOfOrder_Ignored(t *testing.T) {
a, _ := newPipelineTestApp(t)
p := NewStartupPipeline(a)
p.current = 1
p.stages[1].status = "running"
// 嘗試 complete 階段 3current 是 1→ 應該被 ignore
p.CompleteStage(3)
if p.current != 1 {
t.Fatalf("after out-of-order CompleteStage(3), current=%d, want 1", p.current)
}
if p.stages[3].status != "" {
t.Fatalf("stage 3 status=%q, want empty (untouched)", p.stages[3].status)
}
}
func TestStartupPipeline_CompleteStage_LastStageMarksReady(t *testing.T) {
a, _ := newPipelineTestApp(t)
p := NewStartupPipeline(a)
p.current = startupTotalStages
p.stages[startupTotalStages].status = "running"
p.CompleteStage(startupTotalStages)
if p.current != startupTotalStages+1 {
t.Fatalf("after final CompleteStage, current=%d, want %d (ready)", p.current, startupTotalStages+1)
}
if p.stages[startupTotalStages].status != "completed" {
t.Fatalf("final stage status=%q, want completed", p.stages[startupTotalStages].status)
}
}
func TestStartupPipeline_SkipStage_AdvancesAndMarksSkipped(t *testing.T) {
a, _ := newPipelineTestApp(t)
p := NewStartupPipeline(a)
p.current = 5
p.stages[5].status = "running"
p.SkipStage(5)
if p.stages[5].status != "skipped" {
t.Fatalf("stage 5 status=%q, want skipped", p.stages[5].status)
}
if p.current != 6 {
t.Fatalf("after SkipStage(5), current=%d, want 6", p.current)
}
if p.stages[6].status != "running" {
t.Fatalf("stage 6 status=%q, want running", p.stages[6].status)
}
}
func TestStartupPipeline_FailStage_StopsPipeline(t *testing.T) {
a, _ := newPipelineTestApp(t)
p := NewStartupPipeline(a)
p.current = 3
p.stages[3].status = "running"
p.FailStage(3, errors.New("boom"))
if p.stages[3].status != "failed" {
t.Fatalf("stage 3 status=%q, want failed", p.stages[3].status)
}
if p.current != -1 {
t.Fatalf("after FailStage, current=%d, want -1", p.current)
}
// FailStage 觸發 emitError → setState(Error)
if got := a.ctrl.State(); got != ServerStateError {
t.Fatalf("ctrl state after FailStage=%s, want error", got)
}
}
// -----------------------------------------------------------------------
// Watchersoft timeout
// -----------------------------------------------------------------------
func TestStartupPipeline_Watcher_SoftTimeout(t *testing.T) {
a, _ := newPipelineTestApp(t)
p := NewStartupPipeline(a)
// 模擬「階段 2 已經跑了 25 秒」
now := time.Now()
p.startedAt = now.Add(-25 * time.Second) // 總時 25s< 60s hard
p.current = 2
p.stages[2].status = "running"
p.stages[2].startedAt = now.Add(-25 * time.Second) // 階段時間 25s> 20s soft
// 開 watcher等 ~1.2 秒讓它跑一次 tick
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
p.watcherDone = make(chan struct{})
go p.watcher(ctx)
time.Sleep(1300 * time.Millisecond)
p.mu.Lock()
emitted := p.stages[2].softTimeoutEmitted
cur := p.current
p.mu.Unlock()
if !emitted {
t.Fatalf("expected softTimeoutEmitted=true after watcher tick")
}
if cur != 2 {
t.Fatalf("current=%d, want 2 (soft timeout 不中斷流程)", cur)
}
cancel()
<-p.watcherDone
}
// -----------------------------------------------------------------------
// Watcherhard timeout
// -----------------------------------------------------------------------
func TestStartupPipeline_Watcher_HardTimeout(t *testing.T) {
a, _ := newPipelineTestApp(t)
p := NewStartupPipeline(a)
// 模擬「總時已經 305 秒(超過 300 秒 hard timeout當前在階段 3」
now := time.Now()
p.startedAt = now.Add(-305 * time.Second)
p.current = 3
p.stages[3].status = "running"
p.stages[3].startedAt = now.Add(-30 * time.Second)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
p.watcherDone = make(chan struct{})
p.watcherCancel = cancel
go p.watcher(ctx)
time.Sleep(1300 * time.Millisecond)
p.mu.Lock()
cur := p.current
status := p.stages[3].status
p.mu.Unlock()
if cur != -1 {
t.Fatalf("after hard timeout, current=%d, want -1", cur)
}
if status != "failed" {
t.Fatalf("stage 3 status after hard timeout=%q, want failed", status)
}
if got := a.ctrl.State(); got != ServerStateError {
t.Fatalf("ctrl state after hard timeout=%s, want error", got)
}
}
// -----------------------------------------------------------------------
// PauseHardTimeout / ResumeHardTimeout首次 Python bootstrap 豁免
// -----------------------------------------------------------------------
// Pause 期間 sinceTotal 不會累積Resume 後從暫停時點繼續算。
func TestStartupPipeline_PauseHardTimeout_ExcludesPausedDuration(t *testing.T) {
a, _ := newPipelineTestApp(t)
p := NewStartupPipeline(a)
now := time.Now()
p.startedAt = now.Add(-10 * time.Second)
p.current = 2
p.stages[2].status = "running"
p.stages[2].startedAt = now.Add(-10 * time.Second)
// 未暫停effective = 10s
p.mu.Lock()
eff := p.effectiveSinceTotalLocked()
p.mu.Unlock()
if eff < 9*time.Second || eff > 11*time.Second {
t.Fatalf("effective before pause=%s, want ~10s", eff)
}
// 暫停 500ms 再看
p.PauseHardTimeout()
time.Sleep(500 * time.Millisecond)
p.mu.Lock()
effDuringPause := p.effectiveSinceTotalLocked()
p.mu.Unlock()
// 暫停期間 effective 不該跟 wall clock 一起漲 —— 應該還是 ~10s
if effDuringPause > 10500*time.Millisecond {
t.Fatalf("effective during pause=%s, should stay ~10s (paused window excluded)", effDuringPause)
}
// Resume 後再等 200mseffective 繼續前進
p.ResumeHardTimeout()
time.Sleep(200 * time.Millisecond)
p.mu.Lock()
effAfterResume := p.effectiveSinceTotalLocked()
p.mu.Unlock()
// 預期 ~10.2s,誤差允許 300ms
if effAfterResume < 10*time.Second || effAfterResume > 10500*time.Millisecond {
t.Fatalf("effective after resume=%s, want ~10.2s", effAfterResume)
}
}
// 首次 bootstrap 情境:即使 wall clock 已超過 60s只要暫停時間夠多
// hard timeout 就不該觸發。
func TestStartupPipeline_PauseHardTimeout_PreventsHardTimeout(t *testing.T) {
a, _ := newPipelineTestApp(t)
p := NewStartupPipeline(a)
// 模擬 wall clock 已過 300 秒,但其中 250 秒是「首次 bootstrap」暫停
// effective = 50s < 300s hard timeoutpipeline 不該 fail
now := time.Now()
p.startedAt = now.Add(-300 * time.Second)
p.pausedDuration = 250 * time.Second
p.current = 2
p.stages[2].status = "running"
p.stages[2].startedAt = now.Add(-300 * time.Second)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
p.watcherDone = make(chan struct{})
p.watcherCancel = cancel
go p.watcher(ctx)
time.Sleep(1300 * time.Millisecond)
p.mu.Lock()
cur := p.current
status := p.stages[2].status
p.mu.Unlock()
if cur == -1 {
t.Fatal("pipeline failed due to hard timeout, but effective=50s should be under the 300s limit")
}
if status == "failed" {
t.Fatalf("stage 2 failed, want still running (effective time under limit)")
}
// 不 Fatal ctrl state—— watcher 在 test 裡不會被真的 Stop只檢查 pipeline 不進 failed 就好
}
// Resume 未暫停時為 no-op。
func TestStartupPipeline_ResumeHardTimeout_NoopWhenNotPaused(t *testing.T) {
a, _ := newPipelineTestApp(t)
p := NewStartupPipeline(a)
p.startedAt = time.Now()
// 不呼叫 Pause直接 Resume 應不 panic 且 pausedDuration 保持為 0
p.ResumeHardTimeout()
p.mu.Lock()
dur := p.pausedDuration
p.mu.Unlock()
if dur != 0 {
t.Fatalf("pausedDuration after noop resume=%s, want 0", dur)
}
}
// -----------------------------------------------------------------------
// Skip 規則:階段 5/6 + AutoOpenBrowser=false
// -----------------------------------------------------------------------
func TestStartupPipeline_Watcher_SkippedStageNoTimeout(t *testing.T) {
a, _ := newPipelineTestApp(t)
a.prefs.AutoOpenBrowser = false
p := NewStartupPipeline(a)
// 階段 6 + AutoOpenBrowser=false總時 320s已超 300s hard timeout也不該觸發
now := time.Now()
p.startedAt = now.Add(-320 * time.Second)
p.current = 6
p.stages[6].status = "running"
p.stages[6].startedAt = now.Add(-320 * time.Second)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
p.watcherDone = make(chan struct{})
p.watcherCancel = cancel
go p.watcher(ctx)
time.Sleep(1300 * time.Millisecond)
p.mu.Lock()
cur := p.current
status := p.stages[6].status
p.mu.Unlock()
if cur != 6 {
t.Fatalf("stage 6 + AutoOpenBrowser=false should not timeout, current=%d", cur)
}
if status != "running" {
t.Fatalf("stage 6 status=%q, want running (no timeout fired)", status)
}
cancel()
<-p.watcherDone
}
func TestStartupPipeline_Watcher_SkippedStatusBypassesTimeout(t *testing.T) {
a, _ := newPipelineTestApp(t)
p := NewStartupPipeline(a)
// 階段 5 已 skipped總時 320s 不該觸發 hard timeoutskipped 跳過所有檢查)
// 注意skip 之後實際上 current 會是 6但這裡測試的是 skip 狀態本身的 bypass 行為
now := time.Now()
p.startedAt = now.Add(-320 * time.Second)
p.current = 5
p.stages[5].status = "skipped"
p.stages[5].startedAt = now.Add(-30 * time.Second)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
p.watcherDone = make(chan struct{})
p.watcherCancel = cancel
go p.watcher(ctx)
time.Sleep(1300 * time.Millisecond)
p.mu.Lock()
cur := p.current
status := p.stages[5].status
p.mu.Unlock()
if cur != 5 {
t.Fatalf("skipped stage 5 should not be failed, current=%d", cur)
}
if status != "skipped" {
t.Fatalf("stage 5 status=%q, want skipped", status)
}
cancel()
<-p.watcherDone
}
// -----------------------------------------------------------------------
// Sentinel file
// -----------------------------------------------------------------------
func TestStartupPipeline_CheckSentinelFile(t *testing.T) {
a, dir := newPipelineTestApp(t)
p := NewStartupPipeline(a)
if p.checkSentinelFile() {
t.Fatalf("sentinel file 不存在時 checkSentinelFile 應回 false")
}
// 寫一個 sentinel
path := filepath.Join(dir, ".first-ws-connected")
if err := os.WriteFile(path, []byte("bootId=test\n"), 0o644); err != nil {
t.Fatalf("write sentinel: %v", err)
}
if !p.checkSentinelFile() {
t.Fatalf("sentinel file 存在時 checkSentinelFile 應回 true")
}
}
func TestStartupPipeline_RemoveSentinelFile(t *testing.T) {
_, dir := newPipelineTestApp(t)
path := filepath.Join(dir, ".first-ws-connected")
if err := os.WriteFile(path, []byte("x"), 0o644); err != nil {
t.Fatalf("write: %v", err)
}
removeSentinelFile(dir)
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("sentinel file 應該被刪除stat err=%v", err)
}
// 重複呼叫不會出錯
removeSentinelFile(dir)
// 空 dataDir 也不會 panic
removeSentinelFile("")
}
func TestStartupPipeline_Watcher_Stage6CompletesOnSentinel(t *testing.T) {
a, dir := newPipelineTestApp(t)
p := NewStartupPipeline(a)
// 模擬「階段 6 剛 running 1 秒」
now := time.Now()
p.startedAt = now.Add(-30 * time.Second) // 總時 30s< 60s
p.current = 6
p.stages[6].status = "running"
p.stages[6].startedAt = now.Add(-1 * time.Second)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
p.watcherDone = make(chan struct{})
p.watcherCancel = cancel
go p.watcher(ctx)
// 等 watcher 跑一次 tick 確認沒誤判
time.Sleep(300 * time.Millisecond)
p.mu.Lock()
if p.current != 6 {
p.mu.Unlock()
t.Fatalf("watcher 在 sentinel 不存在時就把 current 改了 (=%d)", p.current)
}
p.mu.Unlock()
// 寫 sentinel file等下一次 tick
if err := os.WriteFile(filepath.Join(dir, ".first-ws-connected"), []byte("ok"), 0o644); err != nil {
t.Fatalf("write sentinel: %v", err)
}
time.Sleep(1300 * time.Millisecond)
p.mu.Lock()
cur := p.current
status := p.stages[6].status
p.mu.Unlock()
if cur != startupTotalStages+1 {
t.Fatalf("watcher 看到 sentinel 後 current=%d, want %d (ready)", cur, startupTotalStages+1)
}
if status != "completed" {
t.Fatalf("stage 6 status=%q, want completed", status)
}
}
// -----------------------------------------------------------------------
// NOTE: 以下 2 個原本針對 RestartStartupSequence Wails binding 的測試,於
// AB2+AB3 連同 binding 本身一併刪除:
// - TestStartupPipeline_RestartStartupSequence_StepsExecution
// - TestRestartStartupSequence_ColdStartOpenBrowser_OnlyOnce
//
// 理由visionA Agent 的 3 個配置頁不提供 Retry 按鈕;使用者不直接操作 server
// 生命週期。rebuildStartupPipeline 的內部邏輯仍保留供 startInternal 防呆路徑
// 使用,但不對前端暴露。若未來要加回類似功能,測試也要重新設計。
// -----------------------------------------------------------------------
// -----------------------------------------------------------------------
// M8-4b 補丁 M-1 regressionHasFailedStage + 不重複通知
// -----------------------------------------------------------------------
//
// 驗證 HasFailedStage 的語義:
// - 新 pipelinecurrent=0→ false
// - runningcurrent=2, stage[2]=running→ false
// - 整個 pipeline 已 failcurrent=-1→ true
// - 某 stage status=failed 但 current 還沒切 → 也要回 truedefensive
//
// 這個 helper 是 startInternal 判斷「pipeline 已發過錯誤通知」的依據,
// 必須覆蓋所有 fail 路徑。
func TestStartupPipeline_HasFailedStage(t *testing.T) {
a, _ := newPipelineTestApp(t)
p := NewStartupPipeline(a)
// 1. 新 pipeline → false
if p.HasFailedStage() {
t.Fatalf("new pipeline HasFailedStage should be false")
}
// 2. 正在 running → false
p.current = 2
p.stages[2].status = "running"
if p.HasFailedStage() {
t.Fatalf("running pipeline HasFailedStage should be false")
}
// 3. FailStage 後current=-1→ true
p.FailStage(2, errors.New("test failure"))
if !p.HasFailedStage() {
t.Fatalf("after FailStage HasFailedStage should be true, current=%d", p.current)
}
// 4. 另一種 case某 stage 被手動寫成 failed 但 current 還沒同步defensive
p2 := NewStartupPipeline(a)
p2.current = 3
p2.stages[2].status = "failed" // 不該發生但 defensive
if !p2.HasFailedStage() {
t.Fatalf("any stage failed should make HasFailedStage true")
}
}
// -----------------------------------------------------------------------
// M8-4b 補丁 M-2 regressionIsInColdStart + openBrowser 不被呼叫兩次
// -----------------------------------------------------------------------
//
// 驗證 IsInColdStart 的語義:
// - current = 0未 Start→ false非冷啟動中
// - current = 1..6running→ true冷啟動進行中
// - current = 7ready→ false已完成屬於 RestartServer 等場景)
// - current = -1failed→ false
func TestStartupPipeline_IsInColdStart(t *testing.T) {
a, _ := newPipelineTestApp(t)
p := NewStartupPipeline(a)
// current = 0
if p.IsInColdStart() {
t.Fatalf("current=0 IsInColdStart should be false")
}
// current = 1..6
for i := 1; i <= startupTotalStages; i++ {
p.current = i
if !p.IsInColdStart() {
t.Fatalf("current=%d IsInColdStart should be true", i)
}
}
// current = 7 (ready)
p.current = startupTotalStages + 1
if p.IsInColdStart() {
t.Fatalf("current=7 IsInColdStart should be false (ready)")
}
// current = -1 (failed)
p.current = -1
if p.IsInColdStart() {
t.Fatalf("current=-1 IsInColdStart should be false (failed)")
}
}
// NOTE: TestRestartStartupSequence_ColdStartOpenBrowser_OnlyOnce 已於 AB2+AB3
// 隨 RestartStartupSequence Wails binding 一併刪除visionA Agent 的 UI 不提供
// Retry。openBrowser 變數本身仍在(未來 stub 可能用),保留其他冷啟動/開瀏覽器
// 測試覆蓋此路徑。
// -----------------------------------------------------------------------
// stopWatcher 的 idempotent 行為
// -----------------------------------------------------------------------
func TestStartupPipeline_StopWatcher_Idempotent(t *testing.T) {
a, _ := newPipelineTestApp(t)
p := NewStartupPipeline(a)
// 沒 watcherCancel → 不該 panic
p.stopWatcher()
// 設一個 cancel func呼叫兩次也不該 paniccontext.CancelFunc 文件保證可重複呼叫)
_, cancel := context.WithCancel(context.Background())
p.watcherCancel = cancel
p.stopWatcher()
p.stopWatcher()
}