visionA/local-agent/visiona-agent/server_control_test.go
jim800121chen 3f0175f1a9 feat(local-agent): Phase 0.5 visionA Agent — Wails 桌面 + tunnel client + 配對 UI
從 local-tool 複製出獨立的「visionA Agent」桌面應用(A3 純橋樑:
tunnel client + 配對 UI + 設定,不開 HTTP port、不做本機裝置/推論 UI)。
Bundle ID 與 local-tool 不同(com.innovedus.visiona-agent vs visiona-local),
雙 app 可共存。fork 後不主動 sync,需要時手動 cherry-pick。

Backend / Wails Go(AB1-AB13):
- internal/tunnel:6 狀態機(Idle/Connecting/Connected/Reconnecting/Failed/Stopped)
  + Pair/Unpair/Reconnect/Disconnect binding + ClientHooks event
- internal/auth:encrypted file token store(AES-GCM + scrypt + machineID
  fallback salt + 13 tests)
- internal/config:YAML validation + atomic write + 11 tests
- internal/log:ring buffer + ExportLog 升級 zip
- visionA-backend /api/pairing/exchange:SessionTokenStore + 17 new tests
- 三平台 build 驗證(macOS DMG 160 MB / Windows EXE / Linux AppImage)
- end-to-end 5 milestone 全綠(pairing → tunnel → forward → reuse 防護
  → tunnel drop failover)

Frontend / Next.js(AF1-AF7,沿用 visionA-frontend 基礎):
- AppShell + Header + TabNav(StatusView / PairView / SettingsView 三 tab)
- ConnectionStatusBadge 5 種狀態
- TokenInput regex 驗證 + 7 種錯誤 + 0.5s auto-switch 到狀態頁
- 設定頁 4 區塊(含重新配對 AlertDialog)
- agent-api.ts 封裝 Wails bindings(mock/real 雙實作)+ 90 tests

Phase 0.7 review-driven fix(Round 2):
- A1 Session fixation 防護(RotateSessionID)
- A3 mock pairing 預設改 false(必須明確 opt-in)+ startup log
- A4 Pair 失敗後 state 清理矩陣(exchange/Save/Start fail 各自終態)
- A5 Pair/Unpair/Reconnect lifecycleMu + 50 goroutine race test
- F1 重新配對次按鈕 / F2 PairView Esc cancel / F3 Wails BrowserOpenURL
  / F4 Settings draft 持久 + 未儲存 badge

驗證:agent backend go test -race -count=3 ./... 4 packages 全綠 /
agent frontend pnpm test 119 tests 全綠

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 11:22:01 +08:00

348 lines
11 KiB
Go
Raw Permalink 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-agent-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-agent-test/logs" {
t.Fatalf("info.LogsDir=%q", info.LogsDir)
}
if info.Platform == "" {
t.Fatalf("info.Platform empty")
}
}
// NOTE: TestClearLogs_ResetBuffer 已隨 ClearLogs Wails binding 於 AB2+AB3 一併刪除。
// LogBuffer.Reset() 本身的測試保留在 log_buffer_test.go。
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)
}
}