使用者在 Windows 乾淨環境跑 installer 後首次啟動,看到「伺服器無法
啟動」紅 banner + Settings Stop 卡住。根因:
Stage 2 ensurePythonRuntime 在首次 bootstrap 要做 (1) 解壓 ~15MB
Python tarball (2) 建 venv (3) pip install 9 個 wheel(含 numpy 20MB
+ opencv 50MB + KneronPLUS 等 ~150MB 解壓後)。乾淨 Windows 環境上
這三步合計 2-5 分鐘,遠超 R5-E1 的 60 秒 startup hard timeout,導致
pipeline FailStage + emitError(total-timeout) → Error state → 紅 banner。
R5-E1 的 60 秒預算是針對「日常啟動」,不含首次一次性 bootstrap。
修法:StartupPipeline 加 PauseHardTimeout / ResumeHardTimeout API,
app.go 在 ensureBundledPython 偵測到「真正 bootstrap」條件(pythonBin
不存在)時呼叫 Pause,defer Resume。暫停期間 sinceTotal 扣掉 paused
duration,hard timeout 不觸發。Soft timeout(每階段 20 秒「正在重試」
hint)照常,使用者仍能看到進度提示。
配套:修 killStaleServerOnPort 識別 go run 編出來的 server(Bug A)。
原本用 ps -o comm= 比對 "visiona-local-server" 字串,但 go run 產物
comm 只是 "server"(或 "exe"),生產環境不受影響,但開發 / Reviewer
測試流程會踩到(早上 M8-4 Reviewer 留了一組 go run server 孤兒占住
port 3721 到現在)。改用 ps -o args= 取完整 command line,匹配 規則:
1. 含 "visiona-local-server" — packaged binary
2. 含 "/go-build" 且含 "visiona-local/server" 或 "/exe/server" — go run
驗證:
- visiona-local 套件 go build / vet / test / test -race 全綠
- server 套件 go build / vet / test 全綠
- 3 個新 unit test 通過:
- PauseHardTimeout_ExcludesPausedDuration(effective 時鐘正確扣除)
- PauseHardTimeout_PreventsHardTimeout(wall clock 120s + paused 90s
= effective 30s,不觸發 60s hard timeout)
- ResumeHardTimeout_NoopWhenNotPaused(idempotent)
- macOS dmg 重 build 163MB OK
待做(M8-10b):使用者在 Windows 乾淨環境重新 install + 驗證首次啟動。
如果仍失敗,Windows log 位置:
%APPDATA%\visiona-local\logs\server.stdout.log
%APPDATA%\visiona-local\logs\server.stderr.log
%APPDATA%\visiona-local\logs\wails.log(若有)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
774 lines
25 KiB
Go
774 lines
25 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-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()
|
||
}
|