依 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>
232 lines
5.9 KiB
Go
232 lines
5.9 KiB
Go
package handlers
|
||
|
||
// system_handler_test.go — MAJ-4 補丁:shutdown-notify endpoint 單元測試
|
||
//
|
||
// 驗證 POST /api/system/shutdown-notify 的行為:
|
||
// 1. reason=quit → 200 + 廣播 payload.reason = "quit"
|
||
// 2. reason=restart → 200 + 廣播 payload.reason = "restart"
|
||
// 3. reason=invalid / 空 → 200 + 廣播 payload.reason = "unknown"
|
||
// 4. wsHub = nil → 仍回 200(不 panic)
|
||
//
|
||
// 用 spy broadcaster 代替 real *ws.Hub,避免測試需要真的 goroutine / channel。
|
||
|
||
import (
|
||
"encoding/json"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"sync"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
)
|
||
|
||
func init() {
|
||
gin.SetMode(gin.TestMode)
|
||
}
|
||
|
||
// spyBroadcaster 實作 shutdownNotifyBroadcaster,把每次呼叫紀錄起來供斷言。
|
||
type spyBroadcaster struct {
|
||
mu sync.Mutex
|
||
calls []spyCall
|
||
}
|
||
|
||
type spyCall struct {
|
||
room string
|
||
data map[string]interface{}
|
||
}
|
||
|
||
func (s *spyBroadcaster) BroadcastToRoom(room string, data interface{}) {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
// 把 gin.H 轉成 map[string]interface{} 方便比對
|
||
m := map[string]interface{}{}
|
||
switch v := data.(type) {
|
||
case gin.H:
|
||
for k, val := range v {
|
||
m[k] = val
|
||
}
|
||
case map[string]interface{}:
|
||
m = v
|
||
default:
|
||
// 最後一招:透過 JSON round-trip
|
||
b, _ := json.Marshal(data)
|
||
_ = json.Unmarshal(b, &m)
|
||
}
|
||
s.calls = append(s.calls, spyCall{room: room, data: m})
|
||
}
|
||
|
||
func (s *spyBroadcaster) snapshot() []spyCall {
|
||
s.mu.Lock()
|
||
defer s.mu.Unlock()
|
||
out := make([]spyCall, len(s.calls))
|
||
copy(out, s.calls)
|
||
return out
|
||
}
|
||
|
||
// newTestHandler 組一個 SystemHandler 但用 spy broadcaster 替代 real Hub。
|
||
func newTestHandler(spy *spyBroadcaster) *SystemHandler {
|
||
h := &SystemHandler{
|
||
startTime: time.Now(),
|
||
version: "test",
|
||
buildTime: "test-build",
|
||
bootID: "test-boot-id",
|
||
}
|
||
if spy != nil {
|
||
h.wsHub = spy
|
||
}
|
||
return h
|
||
}
|
||
|
||
// newTestRouter 建一個只掛 shutdown-notify 的最小 router。
|
||
func newTestRouter(h *SystemHandler) *gin.Engine {
|
||
r := gin.New()
|
||
r.POST("/api/system/shutdown-notify", h.ShutdownNotify)
|
||
return r
|
||
}
|
||
|
||
// 整組測試前把 sleep 時間歸零,避免拖慢 test suite。
|
||
func withNoSleep(t *testing.T) {
|
||
t.Helper()
|
||
orig := shutdownNotifySleepDuration
|
||
shutdownNotifySleepDuration = 0
|
||
t.Cleanup(func() {
|
||
shutdownNotifySleepDuration = orig
|
||
})
|
||
}
|
||
|
||
func TestShutdownNotify_ReasonQuit(t *testing.T) {
|
||
withNoSleep(t)
|
||
|
||
spy := &spyBroadcaster{}
|
||
h := newTestHandler(spy)
|
||
r := newTestRouter(h)
|
||
|
||
req := httptest.NewRequest(http.MethodPost, "/api/system/shutdown-notify?reason=quit", nil)
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, req)
|
||
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("want 200, got %d; body=%s", w.Code, w.Body.String())
|
||
}
|
||
|
||
var body struct {
|
||
OK bool `json:"ok"`
|
||
Reason string `json:"reason"`
|
||
}
|
||
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
|
||
t.Fatalf("bad json response: %v", err)
|
||
}
|
||
if !body.OK || body.Reason != "quit" {
|
||
t.Errorf("response fields wrong: %+v", body)
|
||
}
|
||
|
||
calls := spy.snapshot()
|
||
if len(calls) != 1 {
|
||
t.Fatalf("want 1 broadcast, got %d", len(calls))
|
||
}
|
||
if calls[0].room != "system" {
|
||
t.Errorf("want room=system, got %q", calls[0].room)
|
||
}
|
||
if calls[0].data["type"] != "server:shutdown-imminent" {
|
||
t.Errorf("want type=server:shutdown-imminent, got %v", calls[0].data["type"])
|
||
}
|
||
if calls[0].data["reason"] != "quit" {
|
||
t.Errorf("want reason=quit, got %v", calls[0].data["reason"])
|
||
}
|
||
if _, ok := calls[0].data["ts"]; !ok {
|
||
t.Errorf("payload missing ts")
|
||
}
|
||
}
|
||
|
||
func TestShutdownNotify_ReasonRestart(t *testing.T) {
|
||
withNoSleep(t)
|
||
|
||
spy := &spyBroadcaster{}
|
||
h := newTestHandler(spy)
|
||
r := newTestRouter(h)
|
||
|
||
req := httptest.NewRequest(http.MethodPost, "/api/system/shutdown-notify?reason=restart", nil)
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, req)
|
||
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("want 200, got %d", w.Code)
|
||
}
|
||
|
||
calls := spy.snapshot()
|
||
if len(calls) != 1 || calls[0].data["reason"] != "restart" {
|
||
t.Fatalf("want 1 broadcast reason=restart, got %+v", calls)
|
||
}
|
||
}
|
||
|
||
func TestShutdownNotify_ReasonInvalid(t *testing.T) {
|
||
withNoSleep(t)
|
||
|
||
cases := []string{"", "halt", "kill9", "QUIT" /* 大小寫不同應視為 unknown */}
|
||
for _, reasonQuery := range cases {
|
||
name := reasonQuery
|
||
if name == "" {
|
||
name = "empty"
|
||
}
|
||
t.Run("reason_"+name, func(t *testing.T) {
|
||
spy := &spyBroadcaster{}
|
||
h := newTestHandler(spy)
|
||
r := newTestRouter(h)
|
||
|
||
url := "/api/system/shutdown-notify"
|
||
if reasonQuery != "" {
|
||
url += "?reason=" + reasonQuery
|
||
}
|
||
req := httptest.NewRequest(http.MethodPost, url, nil)
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, req)
|
||
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("want 200, got %d", w.Code)
|
||
}
|
||
|
||
var body struct {
|
||
OK bool `json:"ok"`
|
||
Reason string `json:"reason"`
|
||
}
|
||
_ = json.Unmarshal(w.Body.Bytes(), &body)
|
||
if body.Reason != "unknown" {
|
||
t.Errorf("want response.reason=unknown, got %q", body.Reason)
|
||
}
|
||
|
||
calls := spy.snapshot()
|
||
if len(calls) != 1 {
|
||
t.Fatalf("want 1 broadcast, got %d", len(calls))
|
||
}
|
||
if calls[0].data["reason"] != "unknown" {
|
||
t.Errorf("want payload.reason=unknown, got %v", calls[0].data["reason"])
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestShutdownNotify_NoHub(t *testing.T) {
|
||
withNoSleep(t)
|
||
|
||
// wsHub = nil 代表單元測試或啟動失敗情境。handler 不應 panic,必須回 200。
|
||
h := newTestHandler(nil)
|
||
r := newTestRouter(h)
|
||
|
||
req := httptest.NewRequest(http.MethodPost, "/api/system/shutdown-notify?reason=quit", nil)
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, req)
|
||
|
||
if w.Code != http.StatusOK {
|
||
t.Fatalf("want 200 even without hub, got %d", w.Code)
|
||
}
|
||
}
|
||
|
||
// TestShutdownNotify_DefaultSleepIsPositive 保護常數不被誤改為 0 在生產路徑上。
|
||
// 單元測試透過 withNoSleep 暫時設為 0,這裡只驗證原始預設值。
|
||
func TestShutdownNotify_DefaultSleepIsPositive(t *testing.T) {
|
||
if shutdownNotifySleepDuration <= 0 {
|
||
t.Errorf("shutdownNotifySleepDuration should be > 0 in production, got %v", shutdownNotifySleepDuration)
|
||
}
|
||
}
|