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 階段 3(current 是 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) } } // ----------------------------------------------------------------------- // Watcher:soft 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 } // ----------------------------------------------------------------------- // Watcher:hard 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 後再等 200ms,effective 繼續前進 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 timeout,pipeline 不該 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 timeout(skipped 跳過所有檢查) // 注意: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 regression:HasFailedStage + 不重複通知 // ----------------------------------------------------------------------- // // 驗證 HasFailedStage 的語義: // - 新 pipeline(current=0)→ false // - running(current=2, stage[2]=running)→ false // - 整個 pipeline 已 fail(current=-1)→ true // - 某 stage status=failed 但 current 還沒切 → 也要回 true(defensive) // // 這個 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 regression:IsInColdStart + openBrowser 不被呼叫兩次 // ----------------------------------------------------------------------- // // 驗證 IsInColdStart 的語義: // - current = 0(未 Start)→ false(非冷啟動中) // - current = 1..6(running)→ true(冷啟動進行中) // - current = 7(ready)→ false(已完成,屬於 RestartServer 等場景) // - current = -1(failed)→ 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,呼叫兩次也不該 panic(context.CancelFunc 文件保證可重複呼叫) _, cancel := context.WithCancel(context.Background()) p.watcherCancel = cancel p.stopWatcher() p.stopWatcher() }