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-local-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) // 模擬「總時已經 65 秒,當前在階段 3」 now := time.Now() p.startedAt = now.Add(-65 * 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 已過 120 秒,但其中 90 秒是「首次 bootstrap」暫停 now := time.Now() p.startedAt = now.Add(-120 * time.Second) p.pausedDuration = 90 * time.Second // effective = 30s < 60s hard p.current = 2 p.stages[2].status = "running" p.stages[2].startedAt = now.Add(-120 * 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=30s should be under the 60s 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:總時 70s 也不該觸發 hard timeout now := time.Now() p.startedAt = now.Add(-70 * time.Second) p.current = 6 p.stages[6].status = "running" p.stages[6].startedAt = now.Add(-70 * 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,總時 65s 不該觸發 hard timeout(skipped 跳過所有檢查) // 注意:skip 之後實際上 current 會是 6,但這裡測試的是 skip 狀態本身的 bypass 行為 now := time.Now() p.startedAt = now.Add(-65 * 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) } } // ----------------------------------------------------------------------- // RestartStartupSequence 5 步驟 // ----------------------------------------------------------------------- // // M8-4b 補丁 M-3 修復:原本的測試手動複製 Step 1-5 邏輯再驗證 side effect, // 未來若改變 RestartStartupSequence 內部順序(例如把 Step 3 拿掉、移動 Step 4 位置) // 這個測試仍會 pass 但實際行為不一致。保護性弱。 // // 重構後:直接呼叫 a.RestartStartupSequence() method 本身, // 用 App.restartStartFn test hook 攔截 Step 6(ctrl.Start)避免真的 spawn server。 // // 驗證項: // 1. Step 1 執行 — pipelineCancelFn 被呼叫(spy 觀察)+ method 返回後被設為 nil(因為有重建 watcher) // 2. Step 2 執行 — ctrl.ForceKill 被呼叫(觀察 proc 被清 + state 經過 Stopped) // 3. Step 3 執行 — state machine 經過 Stopped(可由 setStateLog 觀察轉換順序) // 4. Step 4 執行 — sentinel file 被清(預先寫入,呼叫後應不存在) // 5. Step 5 執行 — startupPipeline 是新 instance,stage 1 == completed、stage 2 == running // 6. Step 6 執行 — restartStartFn 被呼叫(而不是手動拼接) // 7. Step 1-6 順序正確 — 透過 callLog 驗證 func TestStartupPipeline_RestartStartupSequence_StepsExecution(t *testing.T) { a, dir := newPipelineTestApp(t) // 預先寫 sentinel file(模擬上次 Run 的殘留) sentinelPath := filepath.Join(dir, ".first-ws-connected") if err := os.WriteFile(sentinelPath, []byte("old"), 0o644); err != nil { t.Fatalf("write sentinel: %v", err) } // Spy:呼叫順序紀錄 var callLog []string // Step 1 觀察:pipelineCancelFn 被呼叫 pipelineCancelCalled := false a.pipelineCancelFn = func() { pipelineCancelCalled = true callLog = append(callLog, "pipelineCancel") } // 預先建立一個舊 pipeline,看是否會被換掉 oldPipeline := NewStartupPipeline(a) oldPipeline.current = 3 a.startupPipeline = oldPipeline // 預先把 ctrl 切到 Error state(模擬 Retry 的前置條件) a.ctrl.setState(ServerStateError, "previous failure") // Step 6 hook:替換 ctrl.Start,避免 spawn server // 同時記錄「restartStartFn 被呼叫時 sentinel file 是否已清」驗證順序 sentinelClearedBeforeStart := false newPipelineAtStart := (*StartupPipeline)(nil) a.restartStartFn = func() error { callLog = append(callLog, "startFn") if _, err := os.Stat(sentinelPath); os.IsNotExist(err) { sentinelClearedBeforeStart = true } newPipelineAtStart = a.startupPipeline // 模擬 server 成功啟動:把 ctrl 切到 Running(免得後續 runStartupStage5 讀 ctrl.proc) a.ctrl.setState(ServerStateRunning, "") return nil } // AutoOpenBrowser=false 讓 runStartupStage5 走 SkipStage(5) 路徑,避免 openBrowser 被呼叫 a.prefs.AutoOpenBrowser = false // ---- 執行 ---- if err := a.RestartStartupSequence(); err != nil { t.Fatalf("RestartStartupSequence error: %v", err) } // ---- 驗證 ---- // Step 1:pipelineCancelFn 被呼叫 if !pipelineCancelCalled { t.Fatalf("Step 1: pipelineCancelFn 未被呼叫") } // Step 2 + 3:ForceKill → setState(Stopped)(之後 setState 可能再動,但至少要經過) // 因為 Step 6 的 startFn 會把 state 再切回 Running,終態不會是 Stopped, // 改驗證 proc 被清(ForceKill 的 side effect)+ callLog 順序 a.ctrl.mu.Lock() proc := a.ctrl.proc a.ctrl.mu.Unlock() if proc != nil { t.Fatalf("Step 2: ctrl.proc 應已被 ForceKill 清為 nil, got %v", proc) } // Step 4:sentinel file 被清 if _, err := os.Stat(sentinelPath); !os.IsNotExist(err) { t.Fatalf("Step 4: sentinel file 應該被刪除") } // Step 5:startupPipeline 被換掉 if a.startupPipeline == oldPipeline { t.Fatalf("Step 5: startupPipeline 應該是新 instance(不是舊的)") } if a.startupPipeline == nil { t.Fatalf("Step 5: startupPipeline 不該是 nil") } // 重建後 stage 1 直接 completed,current 切到 stage 2 running a.startupPipeline.mu.Lock() stage1Status := a.startupPipeline.stages[1].status stage2Status := a.startupPipeline.stages[2].status curStage := a.startupPipeline.current a.startupPipeline.mu.Unlock() if stage1Status != "completed" { t.Fatalf("Step 5: stage 1 status=%q, want completed", stage1Status) } if stage2Status != "running" { t.Fatalf("Step 5: stage 2 status=%q, want running", stage2Status) } if curStage != 2 { t.Fatalf("Step 5: new pipeline current=%d, want 2", curStage) } // Step 6:restartStartFn 被呼叫 + 呼叫時 sentinel 已清 + 新 pipeline 已存在 if !sentinelClearedBeforeStart { t.Fatalf("Step 6: restartStartFn 呼叫時 sentinel file 應已被清") } if newPipelineAtStart == oldPipeline || newPipelineAtStart == nil { t.Fatalf("Step 6: restartStartFn 呼叫時 startupPipeline 應已重建為新 instance") } // 呼叫順序:Step 1 cancel 必須在 Step 6 start 之前 foundCancel := -1 foundStart := -1 for i, c := range callLog { if c == "pipelineCancel" && foundCancel == -1 { foundCancel = i } if c == "startFn" && foundStart == -1 { foundStart = i } } if foundCancel == -1 { t.Fatalf("order: pipelineCancel 未出現在 callLog=%v", callLog) } if foundStart == -1 { t.Fatalf("order: startFn 未出現在 callLog=%v", callLog) } if foundCancel >= foundStart { t.Fatalf("order: pipelineCancel (%d) 應在 startFn (%d) 之前, callLog=%v", foundCancel, foundStart, callLog) } // 清理新啟動的 watcher goroutine(避免 goroutine leak) if a.pipelineCancelFn != nil { a.pipelineCancelFn() } } // ----------------------------------------------------------------------- // 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)") } } // TestRestartStartupSequence_ColdStartOpenBrowser_OnlyOnce 驗證 M-2: // 冷啟動 pipeline 路徑下,openBrowser 只被呼叫一次(由 runStartupStage5 負責)。 // // 因為 RestartStartupSequence 的 restartStartFn hook 替換了 ctrl.Start, // 可以精準模擬「冷啟動中途成功」的場景並驗證呼叫次數。 // startInternal 的 R5-D3 openBrowser 邏輯在 IsInColdStart()==true 時會 skip。 // 這個 test 驗證 restartStartFn 返回後只有 runStartupStage5 會呼叫 openBrowser。 func TestRestartStartupSequence_ColdStartOpenBrowser_OnlyOnce(t *testing.T) { a, _ := newPipelineTestApp(t) // AutoOpenBrowser=true → runStartupStage5 會呼叫 openBrowser + CompleteStage(5) a.prefs.AutoOpenBrowser = true // Stub openBrowser 為 package var,記呼叫次數 original := openBrowser var openCount int openBrowser = func(url string) error { openCount++ return nil } defer func() { openBrowser = original }() // restartStartFn 模擬 ctrl.Start 成功:設定 proc port 讓 runStartupStage5 能拿到 URL a.restartStartFn = func() error { // fake proc with port a.ctrl.mu.Lock() a.ctrl.proc = &ServerProcess{port: 12345} a.ctrl.mu.Unlock() a.ctrl.setState(ServerStateRunning, "") // 模擬 startServerV2 內部的 stage 2~4 推進(簡化:直接推到 stage 5 running) a.startupPipeline.mu.Lock() a.startupPipeline.stages[2].status = "completed" a.startupPipeline.stages[3].status = "completed" a.startupPipeline.stages[4].status = "completed" a.startupPipeline.stages[5].status = "running" a.startupPipeline.current = 5 a.startupPipeline.mu.Unlock() return nil } if err := a.RestartStartupSequence(); err != nil { t.Fatalf("RestartStartupSequence error: %v", err) } // runStartupStage5 應該被呼叫一次(AutoOpenBrowser=true → openBrowser 一次) if openCount != 1 { t.Fatalf("openBrowser 被呼叫 %d 次, want 1 (只有 runStartupStage5 負責)", openCount) } // 清理 if a.pipelineCancelFn != nil { a.pipelineCancelFn() } } // ----------------------------------------------------------------------- // 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() }