依 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>
678 lines
22 KiB
Go
678 lines
22 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)
|
||
}
|
||
}
|
||
|
||
// -----------------------------------------------------------------------
|
||
// 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()
|
||
}
|