visionA/local-tool/visiona-local/server_control_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

356 lines
11 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
// server_control_test.go — M8-4 ServerController state machine 單元測試
//
// 不實際 spawn server binary那是 integration test 的事)。
// 這裡只驗證:
// 1. 初始 state = Idle
// 2. setState 會更新 state + lastError
// 3. 雙鎖不會 deadlockStart/Stop 併發呼叫不卡住)
// 4. ForceKill 在沒有 proc 時不 panic
// 5. Restart 在沒有 proc 時會嘗試 Start 且使用 preferredPort=0
//
// M8-4 補丁追加regression
// 6. Stop / ForceKill 進場會 cancel watcherMAJ-1
// 7. handleWatchFailure 在 state != Running 時不 transitionMAJ-2
// 8. logPump scanDone 觸發後仍會 drain lineCh 中剩餘行MAJ-5
import (
"context"
"io"
"sync/atomic"
"testing"
"time"
)
func newTestApp() *App {
a := &App{
pythonMode: PythonModeAuto,
}
a.logBuf = NewLogBuffer()
a.ctrl = NewServerController(a)
return a
}
func TestServerController_InitialState(t *testing.T) {
a := newTestApp()
if got := a.ctrl.State(); got != ServerStateIdle {
t.Fatalf("initial state=%s, want %s", got, ServerStateIdle)
}
}
func TestServerController_setState(t *testing.T) {
a := newTestApp()
// ctx 為 nil → setState 不會 emit但仍會更新欄位
a.ctrl.setState(ServerStateError, "simulated")
if got := a.ctrl.State(); got != ServerStateError {
t.Fatalf("state=%s, want error", got)
}
a.ctrl.mu.Lock()
lastErr := a.ctrl.lastError
a.ctrl.mu.Unlock()
if lastErr != "simulated" {
t.Fatalf("lastError=%q, want simulated", lastErr)
}
}
func TestServerController_Stop_FromIdleIsNoOp(t *testing.T) {
a := newTestApp()
if err := a.ctrl.Stop(); err != nil {
t.Fatalf("Stop from Idle returned err=%v", err)
}
if got := a.ctrl.State(); got != ServerStateIdle {
t.Fatalf("state after Stop(Idle)=%s, want Idle", got)
}
}
func TestServerController_Stop_FromErrorIsNoOp(t *testing.T) {
a := newTestApp()
a.ctrl.setState(ServerStateError, "boom")
if err := a.ctrl.Stop(); err != nil {
t.Fatalf("Stop from Error returned err=%v", err)
}
// Stop 從 Error 狀態是 no-opstate 不變
if got := a.ctrl.State(); got != ServerStateError {
t.Fatalf("state after Stop(Error)=%s, want Error", got)
}
}
func TestServerController_ForceKill_NoProcess(t *testing.T) {
a := newTestApp()
// 沒有 procForceKill 應該只把 state 設為 Stopped 並不 panic
if err := a.ctrl.ForceKill(); err != nil {
t.Fatalf("ForceKill err=%v", err)
}
if got := a.ctrl.State(); got != ServerStateStopped {
t.Fatalf("state after ForceKill=%s, want stopped", got)
}
}
func TestServerController_snapshotStatus_Idle(t *testing.T) {
a := newTestApp()
st := a.snapshotStatus()
if st.State != ServerStateIdle {
t.Fatalf("status.State=%s, want idle", st.State)
}
if st.PID != 0 || st.Port != 0 {
t.Fatalf("idle status should have zero PID/Port, got pid=%d port=%d", st.PID, st.Port)
}
}
func TestGetRecentLogs_EmptyBuffer(t *testing.T) {
a := newTestApp()
logs := a.GetRecentLogs(10)
if len(logs) != 0 {
t.Fatalf("empty buffer GetRecentLogs len=%d, want 0", len(logs))
}
}
func TestGetSystemInfo(t *testing.T) {
a := newTestApp()
a.dataDir = "/tmp/visiona-local-test"
info := a.GetSystemInfo()
if info.DataDir != a.dataDir {
t.Fatalf("info.DataDir=%q, want %q", info.DataDir, a.dataDir)
}
if info.LogsDir != "/tmp/visiona-local-test/logs" {
t.Fatalf("info.LogsDir=%q", info.LogsDir)
}
if info.Platform == "" {
t.Fatalf("info.Platform empty")
}
}
func TestClearLogs_ResetBuffer(t *testing.T) {
a := newTestApp()
for i := 0; i < 5; i++ {
a.logBuf.Append(LogLine{Line: "x"})
}
a.ClearLogs()
if a.logBuf.Size() != 0 {
t.Fatalf("buffer size after ClearLogs=%d", a.logBuf.Size())
}
}
func TestGetServerStatusV2_Delegates(t *testing.T) {
a := newTestApp()
st := a.GetServerStatusV2()
if st.State != ServerStateIdle {
t.Fatalf("GetServerStatusV2 state=%s", st.State)
}
}
// -----------------------------------------------------------------------
// MAJ-1 regressionStop / ForceKill 必須 cancel watcher
// -----------------------------------------------------------------------
// installFakeWatcher 模擬 startServerV2 啟動 watcher 的副作用:
// 在 a.watchCancel 安插一個 CancelFunc呼叫時會把 cancelled 設為 1。
// 回傳 cancelled 計數器供測試斷言。
func installFakeWatcher(a *App) *int32 {
var cancelled int32
_, cancel := context.WithCancel(context.Background())
wrappedCancel := func() {
atomic.AddInt32(&cancelled, 1)
cancel()
}
a.mu.Lock()
a.watchCancel = wrappedCancel
a.mu.Unlock()
return &cancelled
}
func TestServerController_Stop_CancelsWatcher_MAJ1(t *testing.T) {
a := newTestApp()
// 先把 state 弄到 Running模擬 server 已啟動
a.ctrl.setState(ServerStateRunning, "")
cancelled := installFakeWatcher(a)
if err := a.ctrl.Stop(); err != nil {
t.Fatalf("Stop returned err=%v", err)
}
// MAJ-1 修復Stop 必須 cancel watcher
if got := atomic.LoadInt32(cancelled); got != 1 {
t.Fatalf("watchCancel call count after Stop=%d, want 1 (MAJ-1)", got)
}
// watchCancel 應被清為 nil避免重複呼叫
a.mu.Lock()
wc := a.watchCancel
a.mu.Unlock()
if wc != nil {
t.Fatalf("a.watchCancel after Stop should be nil (MAJ-1)")
}
if got := a.ctrl.State(); got != ServerStateStopped {
t.Fatalf("state after Stop(Running)=%s, want stopped", got)
}
}
func TestServerController_ForceKill_CancelsWatcher_MAJ1(t *testing.T) {
a := newTestApp()
a.ctrl.setState(ServerStateRunning, "")
cancelled := installFakeWatcher(a)
if err := a.ctrl.ForceKill(); err != nil {
t.Fatalf("ForceKill err=%v", err)
}
if got := atomic.LoadInt32(cancelled); got != 1 {
t.Fatalf("watchCancel call count after ForceKill=%d, want 1 (MAJ-1)", got)
}
a.mu.Lock()
wc := a.watchCancel
a.mu.Unlock()
if wc != nil {
t.Fatalf("a.watchCancel after ForceKill should be nil (MAJ-1)")
}
}
func TestServerController_Stop_NoWatcher_NoPanic_MAJ1(t *testing.T) {
// 沒有 watcher 時 Stop 也不該 panic
a := newTestApp()
a.ctrl.setState(ServerStateRunning, "")
// 不安裝 fake watcher
if err := a.ctrl.Stop(); err != nil {
t.Fatalf("Stop without watcher err=%v", err)
}
}
// -----------------------------------------------------------------------
// MAJ-2 regressionhandleWatchFailure 在 state != Running 時不 transition
// -----------------------------------------------------------------------
func TestHandleWatchFailure_NoOpWhenStopped_MAJ2(t *testing.T) {
a := newTestApp()
// 模擬使用者剛主動 Stop 完成
a.ctrl.setState(ServerStateStopped, "")
// watcher goroutine 在我們 Stop 完之後才偵測到「失敗 3 次」,呼叫 handleWatchFailure。
// 修復前:會把 Stopped 翻成 Error + 發崩潰通知。
// 修復後state != Running直接 return。
a.ctrl.handleWatchFailure(8421, "stale watcher")
if got := a.ctrl.State(); got != ServerStateStopped {
t.Fatalf("state after stale handleWatchFailure=%s, want stopped (MAJ-2)", got)
}
a.ctrl.mu.Lock()
lastErr := a.ctrl.lastError
a.ctrl.mu.Unlock()
if lastErr == "stale watcher" {
t.Fatalf("lastError should not be set when handleWatchFailure no-ops (MAJ-2)")
}
}
func TestHandleWatchFailure_NoOpWhenIdle_MAJ2(t *testing.T) {
a := newTestApp()
// 預設 Idle
a.ctrl.handleWatchFailure(8421, "should not apply")
if got := a.ctrl.State(); got != ServerStateIdle {
t.Fatalf("state after stale handleWatchFailure on Idle=%s, want idle (MAJ-2)", got)
}
}
func TestHandleWatchFailure_NoOpWhenError_MAJ2(t *testing.T) {
a := newTestApp()
a.ctrl.setState(ServerStateError, "first failure")
// 第二次 watcher 失敗不該重複設 state
a.ctrl.handleWatchFailure(8421, "second failure")
if got := a.ctrl.State(); got != ServerStateError {
t.Fatalf("state=%s, want error", got)
}
a.ctrl.mu.Lock()
lastErr := a.ctrl.lastError
a.ctrl.mu.Unlock()
// MAJ-2 修復state 已是 Error 時直接 return不覆蓋 lastError
if lastErr != "first failure" {
t.Fatalf("lastError=%q, want first failure (MAJ-2 should not overwrite)", lastErr)
}
}
func TestHandleWatchFailure_AppliesWhenRunning_MAJ2(t *testing.T) {
// Sanity checkstate == Running 時仍然會正常 transition 到 Error
a := newTestApp()
a.ctrl.setState(ServerStateRunning, "")
a.ctrl.handleWatchFailure(8421, "real failure")
if got := a.ctrl.State(); got != ServerStateError {
t.Fatalf("state=%s, want error", got)
}
a.ctrl.mu.Lock()
lastErr := a.ctrl.lastError
a.ctrl.mu.Unlock()
if lastErr != "real failure" {
t.Fatalf("lastError=%q, want real failure", lastErr)
}
}
// -----------------------------------------------------------------------
// MAJ-5 regressionlogPump scanDone 觸發後 drain lineCh 剩餘行
// -----------------------------------------------------------------------
// fakePipe 模擬 server 子程序的 stdout pipe — 一次回傳預先準備好的 N 行後 EOF。
type fakePipe struct {
data []byte
pos int
}
func (f *fakePipe) Read(p []byte) (int, error) {
if f.pos >= len(f.data) {
return 0, io.EOF
}
n := copy(p, f.data[f.pos:])
f.pos += n
return n, nil
}
func (f *fakePipe) Close() error { return nil }
func TestLogPump_DrainsLineChOnScanDone_MAJ5(t *testing.T) {
a := newTestApp()
// a.ctx == nil 確保不 emit Wails event但 ring buffer 仍會 append
// 注意a.logBuf 已由 newTestApp 初始化
// 準備 50 行假 logscanner 會一口氣讀完 → 全部塞進 lineChcap 128
// → scanner goroutine 結束 → close(scanDone)。
// main loop 可能在 ticker 還沒 tick 前就同時看到 lineCh ready 與 scanDone ready
// 修復前select 隨機選 scanDone case → 直接 return → lineCh 內所有行被丟掉。
// 修復後scanDone case 加 drain所有行都會進 ring buffer。
const lineCount = 50
var buf []byte
for i := 0; i < lineCount; i++ {
buf = append(buf, []byte("test-line-")...)
buf = append(buf, byte('0'+(i/10)))
buf = append(buf, byte('0'+(i%10)))
buf = append(buf, '\n')
}
pipe := &fakePipe{data: buf}
// 同步呼叫 logPump不開 goroutine讓測試確定地等到它 return
done := make(chan struct{})
go func() {
a.logPump(pipe, "stdout", io.Discard)
close(done)
}()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatalf("logPump did not return within 2s")
}
// 驗證ring buffer 內應有全部 50 行MAJ-5 修復前可能只有部分)
got := a.logBuf.Size()
if got != lineCount {
t.Fatalf("ring buffer size after logPump=%d, want %d (MAJ-5: scanDone should drain lineCh)", got, lineCount)
}
// 進一步驗證最後一行有進去(最關鍵 — 通常崩潰 stack trace 是最後幾行)
snap := a.logBuf.Snapshot(1)
if len(snap) != 1 {
t.Fatalf("snapshot last line len=%d", len(snap))
}
if snap[0].Line != "test-line-49" {
t.Fatalf("last line=%q, want test-line-49 (MAJ-5)", snap[0].Line)
}
}