visionA/local-tool/visiona-local/startup_pipeline_test.go
jim800121chen 8cd5751ce3 feat(local-tool): M8 重構 — Wails 控制台 + 瀏覽器 Web UI(R5 決策)
依 R5 五輪決策把 visionA-local 從「Wails 內嵌 Next.js」重構為「Wails
本機伺服器控制台 + 瀏覽器 Web UI」模式(類比 Docker Desktop / Ollama)。

程式碼變動
  - M8-1 砍 yt-dlp 全套(後端 resolver / URL handler / 前端 URL tab /
    Makefile vendor / installer / bootstrap / CI workflow,-555 行)
  - M8-2 砍 Mock 模式全套(driver/mock、mock_camera、Settings runtimeMode、
    VISIONA_MOCK 環境變數,-528 行)
  - M8-3 ffmpeg 從 GPL 切換到 LGPL 混合方案:Windows/Linux 用 BtbN 現成
    LGPL binary,macOS 自 build minimal decoder-only 進 git
    (vendor/ffmpeg/macos/ffmpeg 5.7MB + ffprobe 5.6MB,比 GPL 版省 85% 空間)
  - M8-4 Wails Server Controller:state machine、log ring buffer 2000 行、
    preferences.json atomic write、boot-id、Gin SkipPaths、shutdown 7+1 秒、
    notify_*.go 三平台 OS 通知、watchServer 改 Error state 不 os.Exit
  - M8-4b 啟動階段管線 R5-E:6 階段進度 event、20s soft / 60s hard timeout、
    stage 5/6 skip 規則、sentinel file、RestartStartupSequence 5 步驟
  - M8-5 Wails 控制台 vanilla HTML/JS/CSS(9 檔 ~2012 行)取代 M7-B splash:
    state 視覺、log panel、startup progress panel、Stage 6 manual CTA
    pulse、shutdown modal、Settings、Dark Mode、i18n 中英雙語
  - M8-6 上傳影片副檔名擴充(mp4/avi/mov/mpeg/mpg)
  - M8-7 Web UI Server Offline Overlay(role=alertdialog + focus trap +
    wsEverConnected 容錯 + Page Visibility)
  - M8-8 CORS middleware(127.0.0.1/localhost only + suffix attack 防護)+
    ws/origin.go 獨立 WebSocket CheckOrigin 避 package cycle
  - MAJ-4 server:shutdown-imminent WebSocket broadcast 機制
    (/ws/system endpoint + notifyShutdownImminent helper)
  - M8-9 Boot-ID + 瀏覽器 tab 自動重連(sessionStorage loop guard)

品質
  - ~105+ 新 unit test + race detector (-count=2) 全綠
  - 10 個 milestone 全部通過 Reviewer 審查
  - 三方 v2 + v2.1 文件(PRD / Design Spec / TDD)+ 交叉互審紀錄
    收錄在 .autoflow/

交付前待處理(M8-10)
  - 重跑 make payload-macos 把舊 GPL 77MB binary 換成新 LGPL
  - 三平台 end-to-end build 驗證

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 17:57:54 +08:00

678 lines
22 KiB
Go
Raw 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-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 階段 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)
// 模擬「總時已經 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)
}
}
// -----------------------------------------------------------------------
// 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 timeoutskipped 跳過所有檢查)
// 注意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 6ctrl.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 是新 instancestage 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 1pipelineCancelFn 被呼叫
if !pipelineCancelCalled {
t.Fatalf("Step 1: pipelineCancelFn 未被呼叫")
}
// Step 2 + 3ForceKill → 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 4sentinel file 被清
if _, err := os.Stat(sentinelPath); !os.IsNotExist(err) {
t.Fatalf("Step 4: sentinel file 應該被刪除")
}
// Step 5startupPipeline 被換掉
if a.startupPipeline == oldPipeline {
t.Fatalf("Step 5: startupPipeline 應該是新 instance不是舊的")
}
if a.startupPipeline == nil {
t.Fatalf("Step 5: startupPipeline 不該是 nil")
}
// 重建後 stage 1 直接 completedcurrent 切到 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 6restartStartFn 被呼叫 + 呼叫時 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 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)")
}
}
// 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呼叫兩次也不該 paniccontext.CancelFunc 文件保證可重複呼叫)
_, cancel := context.WithCancel(context.Background())
p.watcherCancel = cancel
p.stopWatcher()
p.stopWatcher()
}