從 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>
586 lines
18 KiB
Go
586 lines
18 KiB
Go
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()
|
||
}
|