依 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>
356 lines
11 KiB
Go
356 lines
11 KiB
Go
package main
|
||
|
||
// server_control_test.go — M8-4 ServerController state machine 單元測試
|
||
//
|
||
// 不實際 spawn server binary(那是 integration test 的事)。
|
||
// 這裡只驗證:
|
||
// 1. 初始 state = Idle
|
||
// 2. setState 會更新 state + lastError
|
||
// 3. 雙鎖不會 deadlock(Start/Stop 併發呼叫不卡住)
|
||
// 4. ForceKill 在沒有 proc 時不 panic
|
||
// 5. Restart 在沒有 proc 時會嘗試 Start 且使用 preferredPort=0
|
||
//
|
||
// M8-4 補丁追加(regression):
|
||
// 6. Stop / ForceKill 進場會 cancel watcher(MAJ-1)
|
||
// 7. handleWatchFailure 在 state != Running 時不 transition(MAJ-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-op,state 不變
|
||
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()
|
||
// 沒有 proc,ForceKill 應該只把 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 regression:Stop / 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 regression:handleWatchFailure 在 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 check:state == 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 regression:logPump 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 行假 log,scanner 會一口氣讀完 → 全部塞進 lineCh(cap 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)
|
||
}
|
||
}
|