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

996 lines
27 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.

// manager_test.go — Tunnel Manager 狀態機 / Pair / Unpair / listener 測試AB5 範圍)。
//
// 測試目標(對齊 TDD §4 + §6.4
//
// - 狀態機 6 種狀態的 transitionnotPaired → connecting → online → reconnecting → online
// - Pairmock mode成功後自動進入 connecting且 token 寫入 TokenStore
// - Unpair 後回到 notPaired 且 TokenStore 被清空
// - Subscribe 會收到 initial snapshot + 後續變更事件
// - Reconnect 會重置 attempt 計數
// - Stop 之後 state 進入 offline若有 token或 notPaired若無
// - 預設 Auto 模式下不會因為 MaxRetry 停止
//
// 注意:本檔不測 yamux 的真實重連 backoff timing會很脆弱而是用 fakeRelay
// 模擬連線成功 + 主動關閉,觀察 Manager state 是否正確反映。
package tunnel
import (
"context"
"errors"
"net"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"visiona-agent/internal/wsconn"
"github.com/gorilla/websocket"
"github.com/hashicorp/yamux"
)
// ---------------------------------------------------------------------
// Test fixtures
// ---------------------------------------------------------------------
// fakeRelay 開一個 WebSocket server 模擬 remote-proxy 的 tunnel endpoint。
// Agent 側連上時 server 端跑 yamux.Server() 並保持 session 打開。
type fakeRelay struct {
server *httptest.Server
upgrader websocket.Upgrader
tokenSeen chan string
sessionsMu sync.Mutex
sessions []*yamux.Session
}
func newFakeRelay(t *testing.T) *fakeRelay {
t.Helper()
fr := &fakeRelay{
upgrader: websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return true }},
tokenSeen: make(chan string, 16),
}
fr.server = httptest.NewServer(http.HandlerFunc(fr.handle))
return fr
}
func (fr *fakeRelay) close() {
fr.sessionsMu.Lock()
for _, s := range fr.sessions {
_ = s.Close()
}
fr.sessionsMu.Unlock()
fr.server.Close()
}
func (fr *fakeRelay) wsURL() string {
u, _ := url.Parse(fr.server.URL)
u.Scheme = "ws"
u.Path = "/tunnel/connect"
return u.String()
}
// closeAllSessions 主動關閉目前所有 yamux session模擬 relay 斷線。
func (fr *fakeRelay) closeAllSessions() {
fr.sessionsMu.Lock()
defer fr.sessionsMu.Unlock()
for _, s := range fr.sessions {
_ = s.Close()
}
fr.sessions = fr.sessions[:0]
}
// waitForSession 輪詢直到 agent 連上來、sessions 中至少有一個 yamux.Session
// 或 timeout。integration_test.go 用來從 relay 端開 stream 測試 forward。
func (fr *fakeRelay) waitForSession(t *testing.T, timeout time.Duration) *yamux.Session {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
fr.sessionsMu.Lock()
if len(fr.sessions) > 0 {
s := fr.sessions[len(fr.sessions)-1]
fr.sessionsMu.Unlock()
return s
}
fr.sessionsMu.Unlock()
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("waitForSession: no session after %v", timeout)
return nil
}
func (fr *fakeRelay) handle(w http.ResponseWriter, r *http.Request) {
token := r.URL.Query().Get("token")
select {
case fr.tokenSeen <- token:
default:
}
ws, err := fr.upgrader.Upgrade(w, r, nil)
if err != nil {
return
}
nc := wsconn.New(ws)
sess, err := yamux.Server(nc, yamux.DefaultConfig())
if err != nil {
_ = ws.Close()
return
}
fr.sessionsMu.Lock()
fr.sessions = append(fr.sessions, sess)
fr.sessionsMu.Unlock()
<-sess.CloseChan()
}
// waitForState 輪詢 Manager 直到 state 符合 want逾時 fail。
func waitForState(t *testing.T, m *Manager, want ConnectionState, timeout time.Duration) {
t.Helper()
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if got := m.Status().State; got == want {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("waitForState: want %v, got %v after %v", want, m.Status().State, timeout)
}
// fakeExchanger 實作 PairingExchanger測試 Pair 流程不打真 HTTP。
type fakeExchanger struct {
result ExchangeResult
err error
calls int32
}
func (f *fakeExchanger) Exchange(pairingToken string) (ExchangeResult, error) {
atomic.AddInt32(&f.calls, 1)
if f.err != nil {
return ExchangeResult{}, f.err
}
return f.result, nil
}
// ---------------------------------------------------------------------
// Basic lifecycle (前置 AB4 測試的 modernized 版本)
// ---------------------------------------------------------------------
// TestManagerMissingConfig缺必要設定時 Start 應回錯誤。
func TestManagerMissingConfig(t *testing.T) {
cases := []struct {
name string
cfg Config
wantErr error
}{
{"no relay url", Config{SessionToken: "t", LocalAddr: "127.0.0.1:1"}, ErrMissingConfig},
{"no token no store", Config{RelayURL: "ws://x", LocalAddr: "127.0.0.1:1"}, ErrNotPaired},
{"no local addr", Config{RelayURL: "ws://x", SessionToken: "t"}, ErrMissingConfig},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
m := NewManager(tc.cfg)
defer m.Close()
err := m.Start(context.Background())
if !errors.Is(err, tc.wantErr) {
t.Errorf("err = %v, want %v", err, tc.wantErr)
}
})
}
}
// TestManagerStartStopManager 能 Start → 收到 token → Stop。
func TestManagerStartStop(t *testing.T) {
fr := newFakeRelay(t)
defer fr.close()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
defer ln.Close()
m := NewManager(Config{
RelayURL: fr.wsURL(),
SessionToken: "vAs_test",
LocalAddr: ln.Addr().String(),
Account: "demo@visionA.local",
})
defer m.Close()
// 初始狀態:有 SessionToken → StateOffline尚未 Start
if got := m.Status().State; got != StateOffline {
t.Errorf("initial state = %v, want offline", got)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := m.Start(ctx); err != nil {
t.Fatalf("Start: %v", err)
}
// 等待 relay 收到 token
select {
case got := <-fr.tokenSeen:
if got != "vAs_test" {
t.Errorf("relay saw token %q, want vAs_test", got)
}
case <-time.After(3 * time.Second):
t.Fatal("timeout waiting for relay to see token")
}
// 應進入 online
waitForState(t, m, StateOnline, 3*time.Second)
// Stop
if err := m.Stop(); err != nil {
t.Fatalf("Stop: %v", err)
}
// 有 token 所以進 offline不是 notPaired
waitForState(t, m, StateOffline, 2*time.Second)
// 再 Stop 一次應該 idempotent
if err := m.Stop(); err != nil {
t.Errorf("second Stop: %v", err)
}
}
// TestManagerDoubleStart連 Start 兩次應回 ErrAlreadyStarted。
func TestManagerDoubleStart(t *testing.T) {
fr := newFakeRelay(t)
defer fr.close()
ln, _ := net.Listen("tcp", "127.0.0.1:0")
defer ln.Close()
m := NewManager(Config{
RelayURL: fr.wsURL(),
SessionToken: "vAs_test",
LocalAddr: ln.Addr().String(),
})
defer m.Close()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := m.Start(ctx); err != nil {
t.Fatalf("first Start: %v", err)
}
defer m.Stop()
if err := m.Start(ctx); !errors.Is(err, ErrAlreadyStarted) {
t.Errorf("second Start err = %v, want ErrAlreadyStarted", err)
}
}
// ---------------------------------------------------------------------
// State machine transitions
// ---------------------------------------------------------------------
// TestStateTransitionConnectingToOnlineStart → connecting → online 的完整流轉。
func TestStateTransitionConnectingToOnline(t *testing.T) {
fr := newFakeRelay(t)
defer fr.close()
ln, _ := net.Listen("tcp", "127.0.0.1:0")
defer ln.Close()
// listener 收集所有狀態變更
var (
mu sync.Mutex
states []ConnectionState
)
m := NewManager(Config{
RelayURL: fr.wsURL(),
SessionToken: "vAs_abc",
LocalAddr: ln.Addr().String(),
})
defer m.Close()
unsub := m.Subscribe(func(s ConnectionStatus) {
mu.Lock()
states = append(states, s.State)
mu.Unlock()
})
defer unsub()
if err := m.Start(context.Background()); err != nil {
t.Fatalf("Start: %v", err)
}
waitForState(t, m, StateOnline, 3*time.Second)
// 驗證觀察到的序列包含 connecting 和 online
time.Sleep(100 * time.Millisecond) // 讓 fanout goroutine 有時間 flush
mu.Lock()
seenConnecting := false
seenOnline := false
for _, s := range states {
if s == StateConnecting {
seenConnecting = true
}
if s == StateOnline {
seenOnline = true
}
}
mu.Unlock()
if !seenConnecting {
t.Error("did not observe StateConnecting transition")
}
if !seenOnline {
t.Error("did not observe StateOnline transition")
}
_ = m.Stop()
}
// TestStateTransitionOnlineToReconnecting連上後 relay 關 session應進入 reconnecting。
// 透過 Subscribe 觀察所有 state transitionpoll 會錯過快速切換的中間狀態)。
func TestStateTransitionOnlineToReconnecting(t *testing.T) {
fr := newFakeRelay(t)
defer fr.close()
ln, _ := net.Listen("tcp", "127.0.0.1:0")
defer ln.Close()
m := NewManager(Config{
RelayURL: fr.wsURL(),
SessionToken: "vAs_test",
LocalAddr: ln.Addr().String(),
})
defer m.Close()
var (
mu sync.Mutex
states []ConnectionState
)
unsub := m.Subscribe(func(s ConnectionStatus) {
mu.Lock()
states = append(states, s.State)
mu.Unlock()
})
defer unsub()
if err := m.Start(context.Background()); err != nil {
t.Fatalf("Start: %v", err)
}
defer m.Stop()
waitForState(t, m, StateOnline, 3*time.Second)
// Relay 主動關 session → Manager 應先進 reconnecting若 relay 仍開著)回 online
fr.closeAllSessions()
// 等到我們在 listener 看到 reconnecting 或 3 秒超時
deadline := time.Now().Add(3 * time.Second)
sawReconnecting := false
for time.Now().Before(deadline) {
mu.Lock()
for _, s := range states {
if s == StateReconnecting {
sawReconnecting = true
break
}
}
mu.Unlock()
if sawReconnecting {
break
}
time.Sleep(20 * time.Millisecond)
}
if !sawReconnecting {
mu.Lock()
t.Errorf("did not observe reconnecting after relay session close; observed states = %v", states)
mu.Unlock()
}
}
// ---------------------------------------------------------------------
// Pair / Unpair
// ---------------------------------------------------------------------
// TestPairSuccessmock mode Pair 成功後 token 存入 TokenStore 並啟動 tunnel。
func TestPairSuccess(t *testing.T) {
fr := newFakeRelay(t)
defer fr.close()
ln, _ := net.Listen("tcp", "127.0.0.1:0")
defer ln.Close()
store := NewMemoryTokenStore()
fakeToken := "vAs_" + "deadbeef" + "0000111122223333444455556666777788889999aaaabbbbccccdddd"
fakeExch := &fakeExchanger{
result: ExchangeResult{
SessionToken: fakeToken,
Account: "pairtest@visionA.local",
RelayURL: fr.wsURL(),
},
}
m := NewManager(Config{
LocalAddr: ln.Addr().String(),
TokenStore: store,
Exchanger: fakeExch,
})
defer m.Close()
// 初始:未配對
if m.Status().State != StateNotPaired {
t.Fatalf("initial state = %v, want notPaired", m.Status().State)
}
pairToken := "vAc_0123456789abcdef0123456789abcdef"
if err := m.Pair(context.Background(), pairToken); err != nil {
t.Fatalf("Pair: %v", err)
}
waitForState(t, m, StateOnline, 3*time.Second)
// TokenStore 應寫入
got, _ := store.Load()
if got != fakeToken {
t.Errorf("TokenStore token = %q, want %q", got, fakeToken)
}
// Account 應更新
if m.Status().Account != "pairtest@visionA.local" {
t.Errorf("Status.Account = %q, want pairtest@visionA.local", m.Status().Account)
}
// Session preview 應遮蔽
if preview := m.Status().SessionTokenPreview; preview == "" || preview == fakeToken {
t.Errorf("SessionTokenPreview should be masked, got %q", preview)
}
_ = m.Stop()
}
// TestPairInvalidTokenFormat格式不合應直接 reject不呼叫 Exchanger。
func TestPairInvalidTokenFormat(t *testing.T) {
fakeExch := &fakeExchanger{}
m := NewManager(Config{
LocalAddr: "127.0.0.1:1",
TokenStore: NewMemoryTokenStore(),
Exchanger: fakeExch,
})
defer m.Close()
err := m.Pair(context.Background(), "bogus-token")
if !errors.Is(err, ErrInvalidTokenFormat) {
t.Errorf("err = %v, want ErrInvalidTokenFormat", err)
}
if atomic.LoadInt32(&fakeExch.calls) != 0 {
t.Errorf("Exchanger.Exchange should not be called for invalid format; calls = %d", fakeExch.calls)
}
}
// TestPairNoExchangerConfigured沒設 Exchanger 應回錯誤。
func TestPairNoExchangerConfigured(t *testing.T) {
m := NewManager(Config{
LocalAddr: "127.0.0.1:1",
TokenStore: NewMemoryTokenStore(),
})
defer m.Close()
err := m.Pair(context.Background(), "vAc_0123456789abcdef0123456789abcdef")
if err == nil {
t.Error("expected error when Exchanger is nil")
}
}
// TestUnpairUnpair 後 token 清空且 state = notPaired。
func TestUnpair(t *testing.T) {
fr := newFakeRelay(t)
defer fr.close()
ln, _ := net.Listen("tcp", "127.0.0.1:0")
defer ln.Close()
store := NewMemoryTokenStore()
_ = store.Save("vAs_already_paired_token")
m := NewManager(Config{
RelayURL: fr.wsURL(),
SessionToken: "vAs_already_paired_token",
LocalAddr: ln.Addr().String(),
TokenStore: store,
})
defer m.Close()
_ = m.Start(context.Background())
waitForState(t, m, StateOnline, 3*time.Second)
if err := m.Unpair(); err != nil {
t.Fatalf("Unpair: %v", err)
}
waitForState(t, m, StateNotPaired, 2*time.Second)
got, _ := store.Load()
if got != "" {
t.Errorf("TokenStore should be empty after Unpair, got %q", got)
}
if m.Status().Account != "" {
t.Errorf("Account should be cleared, got %q", m.Status().Account)
}
}
// ---------------------------------------------------------------------
// Subscribe / fanout
// ---------------------------------------------------------------------
// TestSubscribeReceivesInitialSnapshotSubscribe 後立刻收到當前狀態。
func TestSubscribeReceivesInitialSnapshot(t *testing.T) {
m := NewManager(Config{
RelayURL: "ws://example/relay",
LocalAddr: "127.0.0.1:1",
TokenStore: NewMemoryTokenStore(),
})
defer m.Close()
done := make(chan ConnectionStatus, 1)
unsub := m.Subscribe(func(s ConnectionStatus) {
select {
case done <- s:
default:
}
})
defer unsub()
select {
case s := <-done:
if s.State != StateNotPaired {
t.Errorf("initial snapshot state = %v, want notPaired", s.State)
}
if s.RelayURL != "ws://example/relay" {
t.Errorf("RelayURL = %q, want ws://example/relay", s.RelayURL)
}
case <-time.After(1 * time.Second):
t.Fatal("timeout waiting for initial snapshot")
}
}
// TestSubscribeUnsubscribeunsubscribe 後應不再收到事件。
func TestSubscribeUnsubscribe(t *testing.T) {
m := NewManager(Config{
RelayURL: "ws://example/relay",
LocalAddr: "127.0.0.1:1",
TokenStore: NewMemoryTokenStore(),
})
defer m.Close()
var count int32
unsub := m.Subscribe(func(s ConnectionStatus) {
atomic.AddInt32(&count, 1)
})
// 等 initial snapshot 送達
time.Sleep(50 * time.Millisecond)
atomic.StoreInt32(&count, 0)
unsub()
unsub() // idempotent
// 觸發一次 transitionunsubscribed listener 不該收到
m.transition(StateError, "test", 0)
time.Sleep(50 * time.Millisecond)
if atomic.LoadInt32(&count) != 0 {
t.Errorf("unsubscribed listener still received %d events", count)
}
}
// TestListenerPanicDoesNotCrashManagerlistener panic 不該拖垮 Manager。
func TestListenerPanicDoesNotCrashManager(t *testing.T) {
m := NewManager(Config{
RelayURL: "ws://x",
LocalAddr: "127.0.0.1:1",
TokenStore: NewMemoryTokenStore(),
})
defer m.Close()
unsub := m.Subscribe(func(s ConnectionStatus) {
panic("test panic in listener")
})
defer unsub()
// 觸發幾次 transition
m.transition(StateConnecting, "", 1)
m.transition(StateError, "boom", 0)
time.Sleep(100 * time.Millisecond)
// 沒有 panic 拖垮 goroutine 就算通過
}
// ---------------------------------------------------------------------
// Reconnect
// ---------------------------------------------------------------------
// TestReconnectResetsAttemptReconnect 應把 attemptNo 歸零。
func TestReconnectResetsAttempt(t *testing.T) {
fr := newFakeRelay(t)
defer fr.close()
ln, _ := net.Listen("tcp", "127.0.0.1:0")
defer ln.Close()
m := NewManager(Config{
RelayURL: fr.wsURL(),
SessionToken: "vAs_test",
LocalAddr: ln.Addr().String(),
})
defer m.Close()
_ = m.Start(context.Background())
waitForState(t, m, StateOnline, 3*time.Second)
// 人工塞 attempt 數
atomic.StoreInt32(&m.attemptNo, 42)
if err := m.Reconnect(context.Background()); err != nil {
t.Fatalf("Reconnect: %v", err)
}
waitForState(t, m, StateOnline, 3*time.Second)
if got := atomic.LoadInt32(&m.attemptNo); got != 0 {
t.Errorf("attemptNo after Reconnect = %d, want 0", got)
}
}
// ---------------------------------------------------------------------
// Backoff / client.go 已有測試覆蓋Manager 這層只確認 Auto 模式不會因 MaxRetry 停止。
// ---------------------------------------------------------------------
// TestAutoModeNeverStopsForMaxRetryAuto 模式下 attempt 超過 MaxRetry 仍不呼叫 Stop。
// 因為真實重試很慢backoff用 hook 直接測 shouldStopForMaxRetry 的邏輯。
func TestAutoModeNeverStopsForMaxRetry(t *testing.T) {
m := NewManager(Config{
RelayURL: "ws://x",
LocalAddr: "127.0.0.1:1",
TokenStore: NewMemoryTokenStore(),
MaxRetry: 3,
ReconnectMode: ReconnectAuto,
})
defer m.Close()
for i := 1; i <= 10; i++ {
if m.shouldStopForMaxRetry(i) {
t.Errorf("Auto mode should never stop (attempt=%d)", i)
}
}
}
// TestManualModeStopsAfterMaxRetryManual 模式 attempt > MaxRetry 後 shouldStopForMaxRetry 回 true。
func TestManualModeStopsAfterMaxRetry(t *testing.T) {
m := NewManager(Config{
RelayURL: "ws://x",
LocalAddr: "127.0.0.1:1",
TokenStore: NewMemoryTokenStore(),
MaxRetry: 3,
ReconnectMode: ReconnectManual,
})
defer m.Close()
cases := []struct {
attempt int
want bool
}{
{1, false},
{2, false},
{3, false}, // 剛好 = MaxRetry還不算超過
{4, true},
{5, true},
{100, true},
}
for _, tc := range cases {
if got := m.shouldStopForMaxRetry(tc.attempt); got != tc.want {
t.Errorf("shouldStopForMaxRetry(%d) = %v, want %v", tc.attempt, got, tc.want)
}
}
}
// ---------------------------------------------------------------------
// TokenStore integration
// ---------------------------------------------------------------------
// TestStartLoadsTokenFromStoreConfig.SessionToken 為空時應從 TokenStore 載入。
func TestStartLoadsTokenFromStore(t *testing.T) {
fr := newFakeRelay(t)
defer fr.close()
ln, _ := net.Listen("tcp", "127.0.0.1:0")
defer ln.Close()
store := NewMemoryTokenStore()
_ = store.Save("vAs_from_store")
m := NewManager(Config{
RelayURL: fr.wsURL(),
LocalAddr: ln.Addr().String(),
TokenStore: store,
// SessionToken 刻意留空
})
defer m.Close()
if err := m.Start(context.Background()); err != nil {
t.Fatalf("Start: %v", err)
}
defer m.Stop()
// Relay 看到的 token 應該是 store 裡的
select {
case got := <-fr.tokenSeen:
if got != "vAs_from_store" {
t.Errorf("relay saw token %q, want vAs_from_store", got)
}
case <-time.After(3 * time.Second):
t.Fatal("timeout")
}
}
// ---------------------------------------------------------------------
// Fix-A4Pair 失敗 / 部分失敗 state 清理測試
// ---------------------------------------------------------------------
// failingExchanger 模擬 exchange 失敗。
type failingExchanger struct {
err error
}
func (f *failingExchanger) Exchange(_ string) (ExchangeResult, error) {
return ExchangeResult{}, f.err
}
// failingTokenStore 模擬 Save 失敗(其他方法正常)。
type failingTokenStore struct {
saveErr error
deleteErr error
mu sync.RWMutex
tok string
}
func (s *failingTokenStore) Save(tok string) error {
if s.saveErr != nil {
return s.saveErr
}
s.mu.Lock()
defer s.mu.Unlock()
s.tok = tok
return nil
}
func (s *failingTokenStore) Load() (string, error) {
s.mu.RLock()
defer s.mu.RUnlock()
return s.tok, nil
}
func (s *failingTokenStore) Delete() error {
if s.deleteErr != nil {
return s.deleteErr
}
s.mu.Lock()
defer s.mu.Unlock()
s.tok = ""
return nil
}
// TestPairExchangeFailure_NoStateChangeexchange 失敗 → state 維持 notPairedTokenStore 不變。
func TestPairExchangeFailure_NoStateChange(t *testing.T) {
store := NewMemoryTokenStore()
m := NewManager(Config{
LocalAddr: "127.0.0.1:1",
TokenStore: store,
Exchanger: &failingExchanger{err: ErrTokenExpired},
})
defer m.Close()
preState := m.Status().State
err := m.Pair(context.Background(), "vAc_0123456789abcdef0123456789abcdef")
if !errors.Is(err, ErrTokenExpired) {
t.Fatalf("err = %v, want ErrTokenExpired", err)
}
if got := m.Status().State; got != preState {
t.Errorf("state changed unexpectedly: %v → %v", preState, got)
}
if tok, _ := store.Load(); tok != "" {
t.Errorf("TokenStore should be empty, got %q", tok)
}
}
// TestPairSaveFailure_NoStateChange_NoTokenexchange OK + Save 失敗 → state 不變token 沒存。
func TestPairSaveFailure_NoStateChange_NoToken(t *testing.T) {
store := &failingTokenStore{saveErr: errors.New("disk full")}
m := NewManager(Config{
LocalAddr: "127.0.0.1:1",
TokenStore: store,
Exchanger: &fakeExchanger{
result: ExchangeResult{
SessionToken: "vAs_token_xyz",
Account: "x@y",
},
},
})
defer m.Close()
preState := m.Status().State
err := m.Pair(context.Background(), "vAc_0123456789abcdef0123456789abcdef")
if err == nil {
t.Fatalf("expected error from Save failure")
}
if got := m.Status().State; got != preState {
t.Errorf("state changed: %v → %v (Pair Save failure should not transition)", preState, got)
}
if tok, _ := store.Load(); tok != "" {
t.Errorf("token should not be persisted on Save fail, got %q", tok)
}
// cfg.SessionToken 不該被更新(因為 Save 失敗在更新前 return
if got := m.sessionToken(); got != "" {
t.Errorf("cfg.SessionToken should remain empty on Save fail, got %q", got)
}
}
// TestPairStartFailure_TokenStoredStateErrorexchange + Save OK 但 Start 失敗
// → token 已存(可下次 Reconnect 試state = error。
func TestPairStartFailure_TokenStoredStateError(t *testing.T) {
store := NewMemoryTokenStore()
m := NewManager(Config{
// 故意不給 LocalAddr → Start 會回 ErrMissingConfig
TokenStore: store,
Exchanger: &fakeExchanger{
result: ExchangeResult{
SessionToken: "vAs_paired_but_cant_start",
RelayURL: "ws://example/relay",
Account: "x@y",
},
},
})
defer m.Close()
err := m.Pair(context.Background(), "vAc_0123456789abcdef0123456789abcdef")
if !errors.Is(err, ErrMissingConfig) {
t.Fatalf("err = %v, want ErrMissingConfig", err)
}
// Token 已持久化Reconnect 可重試)
if tok, _ := store.Load(); tok != "vAs_paired_but_cant_start" {
t.Errorf("token should be persisted even when Start fails, got %q", tok)
}
// state = errorUI 可顯示「配對成功但連線失敗」
if got := m.Status().State; got != StateError {
t.Errorf("state = %v, want StateError after Start fail", got)
}
if errStr := m.Status().LastError; errStr == "" {
t.Error("LastError should be set on Start failure")
}
}
// ---------------------------------------------------------------------
// Fix-A5Lifecycle method 並發保護測試
// ---------------------------------------------------------------------
// TestLifecycleConcurrentRaceFree50 個 goroutine 同時呼叫 Pair / Reconnect / Unpair / Start / Stop
// 應該 race-freego test -race 不報錯)+ 不 panic。
//
// 不驗證最終 state 是哪個(取決於哪個 lifecycle method 最後贏),只驗證:
// - 沒 race detector 警告
// - 沒 panicemit / state machine / store 不會 corrupt
// - Manager 仍處於可用狀態(最後 Status() 可取)
func TestLifecycleConcurrentRaceFree(t *testing.T) {
fr := newFakeRelay(t)
defer fr.close()
ln, _ := net.Listen("tcp", "127.0.0.1:0")
defer ln.Close()
store := NewMemoryTokenStore()
_ = store.Save("vAs_initial_token")
m := NewManager(Config{
RelayURL: fr.wsURL(),
SessionToken: "vAs_initial_token",
LocalAddr: ln.Addr().String(),
TokenStore: store,
Exchanger: &fakeExchanger{
result: ExchangeResult{
SessionToken: "vAs_paired_" + strings.Repeat("a", 56),
Account: "concurrent@test",
RelayURL: fr.wsURL(),
},
},
MaxRetry: 1,
ReconnectMode: ReconnectAuto,
})
defer m.Close()
const numGoroutines = 50
var wg sync.WaitGroup
wg.Add(numGoroutines)
pairToken := "vAc_0123456789abcdef0123456789abcdef"
ctx := context.Background()
for i := 0; i < numGoroutines; i++ {
go func(idx int) {
defer wg.Done()
defer func() {
// 任何 panic 都讓測試 fail透過 recover 確保 wg.Done 仍跑)
if r := recover(); r != nil {
t.Errorf("goroutine %d panicked: %v", idx, r)
}
}()
switch idx % 5 {
case 0:
_ = m.Pair(ctx, pairToken)
case 1:
_ = m.Reconnect(ctx)
case 2:
_ = m.Unpair()
case 3:
_ = m.Start(ctx)
case 4:
_ = m.Stop()
}
}(i)
}
wg.Wait()
// Manager 仍可用Status() 不 panic、可取到合法 ConnectionStatus
snap := m.Status()
switch snap.State {
case StateNotPaired, StateConnecting, StateOnline, StateReconnecting, StateOffline, StateError:
// OK任一合法狀態都接受
default:
t.Errorf("Status() returned invalid state: %q", snap.State)
}
// attemptNo 不該爆出負值或極端值
if snap.AttemptNo < 0 {
t.Errorf("AttemptNo negative: %d", snap.AttemptNo)
}
}
// TestPairReconnectSerializationPair 和 Reconnect 並發呼叫應該序列化執行
// (透過驗證 attemptNo 不會混亂)。
//
// 場景:使用者點 Pair 按鈕的同時又連點 Reconnect → 沒有 lifecycleMu 時 attempt 計數
// 可能在兩個 goroutine 間交錯增減;有 lifecycleMu 後兩個操作序列化。
func TestPairReconnectSerialization(t *testing.T) {
fr := newFakeRelay(t)
defer fr.close()
ln, _ := net.Listen("tcp", "127.0.0.1:0")
defer ln.Close()
m := NewManager(Config{
LocalAddr: ln.Addr().String(),
TokenStore: NewMemoryTokenStore(),
Exchanger: &fakeExchanger{
result: ExchangeResult{
SessionToken: "vAs_" + strings.Repeat("b", 64),
Account: "serialize@test",
RelayURL: fr.wsURL(),
},
},
})
defer m.Close()
// 先 Pair 一次讓 manager 進入 running
if err := m.Pair(context.Background(), "vAc_0123456789abcdef0123456789abcdef"); err != nil {
t.Fatalf("initial Pair: %v", err)
}
waitForState(t, m, StateOnline, 3*time.Second)
// 並發呼叫多次 Reconnect — 每次都應該序列化執行(不會 race
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_ = m.Reconnect(context.Background())
}()
}
wg.Wait()
// 等待 manager 穩定回 online
waitForState(t, m, StateOnline, 5*time.Second)
// Manager 仍可用attemptNo 應為 0最後一次 Reconnect 後 onSessionUp 重置)
if got := atomic.LoadInt32(&m.attemptNo); got < 0 {
t.Errorf("attemptNo invalid: %d", got)
}
}
// TestMaskSessionToken 驗證遮蔽格式。
func TestMaskSessionToken(t *testing.T) {
cases := []struct {
in string
want string
}{
{"vAs_a1b2c3d4e5f6789abcdef0000111122223333444455556666777788889999e7f8", "vAs_a1b2c3d4 ··· e7f8"},
{"not-a-token", ""},
{"vAs_short", ""},
}
for _, tc := range cases {
if got := MaskSessionToken(tc.in); got != tc.want {
t.Errorf("MaskSessionToken(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}