從 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>
242 lines
6.3 KiB
Go
242 lines
6.3 KiB
Go
// agentconfig 測試 — AB9 範圍。
|
||
|
||
package agentconfig
|
||
|
||
import (
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
func TestDefault_MatchesTDD(t *testing.T) {
|
||
d := Default()
|
||
if d.Version != currentSchemaVersion {
|
||
t.Errorf("Version = %d; want %d", d.Version, currentSchemaVersion)
|
||
}
|
||
if d.Relay.URL != DefaultRelayURL {
|
||
t.Errorf("Relay.URL = %q; want %q", d.Relay.URL, DefaultRelayURL)
|
||
}
|
||
if d.Relay.Reconnect != ReconnectAuto {
|
||
t.Errorf("Relay.Reconnect = %q; want auto", d.Relay.Reconnect)
|
||
}
|
||
if d.Behavior.Autostart != false {
|
||
t.Error("Behavior.Autostart should default false")
|
||
}
|
||
if d.Log.Level != LogLevelInfo {
|
||
t.Errorf("Log.Level = %q; want info", d.Log.Level)
|
||
}
|
||
}
|
||
|
||
func TestValidate_AcceptsDefaults(t *testing.T) {
|
||
cfg := Default()
|
||
if err := cfg.Validate(); err != nil {
|
||
t.Errorf("Default should be valid; err = %v", err)
|
||
}
|
||
}
|
||
|
||
func TestValidate_RejectsBadRelayURL(t *testing.T) {
|
||
cases := []string{
|
||
"",
|
||
"http://relay.example.com",
|
||
"https://relay.example.com",
|
||
"relay.example.com",
|
||
"wss",
|
||
}
|
||
for _, url := range cases {
|
||
cfg := Default()
|
||
cfg.Relay.URL = url
|
||
if err := cfg.Validate(); err == nil {
|
||
t.Errorf("Validate should reject Relay.URL=%q", url)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestValidate_AcceptsWsAndWss(t *testing.T) {
|
||
for _, url := range []string{"ws://localhost:8080/tunnel", "wss://relay.example.com/tunnel/connect"} {
|
||
cfg := Default()
|
||
cfg.Relay.URL = url
|
||
if err := cfg.Validate(); err != nil {
|
||
t.Errorf("Validate should accept %q; err = %v", url, err)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestValidate_RejectsBadReconnectMode(t *testing.T) {
|
||
cfg := Default()
|
||
cfg.Relay.Reconnect = "never"
|
||
if err := cfg.Validate(); err == nil {
|
||
t.Error("Validate should reject Reconnect=never")
|
||
}
|
||
}
|
||
|
||
func TestValidate_RejectsBadLogLevel(t *testing.T) {
|
||
cfg := Default()
|
||
cfg.Log.Level = "trace"
|
||
if err := cfg.Validate(); err == nil {
|
||
t.Error("Validate should reject Log.Level=trace")
|
||
}
|
||
}
|
||
|
||
func TestValidate_ReportsAllErrors(t *testing.T) {
|
||
cfg := Default()
|
||
cfg.Relay.URL = "bad"
|
||
cfg.Relay.Reconnect = "maybe"
|
||
cfg.Log.Level = "trace"
|
||
err := cfg.Validate()
|
||
if err == nil {
|
||
t.Fatal("Validate should fail on 3 bad fields")
|
||
}
|
||
msg := err.Error()
|
||
for _, needle := range []string{"relay.url", "relay.reconnect", "log.level"} {
|
||
if !strings.Contains(msg, needle) {
|
||
t.Errorf("Validate error should mention %q; got: %s", needle, msg)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestNewStore_MissingFileUsesDefaults(t *testing.T) {
|
||
dir := t.TempDir()
|
||
s, err := NewStore(dir, nil)
|
||
if err != nil {
|
||
t.Fatalf("NewStore: %v", err)
|
||
}
|
||
cfg := s.Get()
|
||
if cfg.Relay.URL != DefaultRelayURL {
|
||
t.Errorf("missing file should yield default URL; got %q", cfg.Relay.URL)
|
||
}
|
||
// 首次啟動應該把預設值寫下去
|
||
if _, err := os.Stat(s.Path()); err != nil {
|
||
t.Errorf("config.yaml should be created on first launch; stat err = %v", err)
|
||
}
|
||
}
|
||
|
||
func TestNewStore_SaveThenReload(t *testing.T) {
|
||
dir := t.TempDir()
|
||
s1, err := NewStore(dir, nil)
|
||
if err != nil {
|
||
t.Fatalf("NewStore s1: %v", err)
|
||
}
|
||
newCfg := s1.Get()
|
||
newCfg.Relay.URL = "wss://custom.relay.example.com/tunnel"
|
||
newCfg.Relay.Reconnect = ReconnectManual
|
||
newCfg.Behavior.Autostart = true
|
||
newCfg.Log.Level = LogLevelDebug
|
||
|
||
if err := s1.Save(newCfg); err != nil {
|
||
t.Fatalf("Save: %v", err)
|
||
}
|
||
|
||
// Reopen → 值還在
|
||
s2, err := NewStore(dir, nil)
|
||
if err != nil {
|
||
t.Fatalf("NewStore s2: %v", err)
|
||
}
|
||
got := s2.Get()
|
||
if got.Relay.URL != newCfg.Relay.URL {
|
||
t.Errorf("Relay.URL = %q; want %q", got.Relay.URL, newCfg.Relay.URL)
|
||
}
|
||
if got.Relay.Reconnect != ReconnectManual {
|
||
t.Errorf("Relay.Reconnect = %q; want manual", got.Relay.Reconnect)
|
||
}
|
||
if !got.Behavior.Autostart {
|
||
t.Error("Behavior.Autostart = false; want true")
|
||
}
|
||
if got.Log.Level != LogLevelDebug {
|
||
t.Errorf("Log.Level = %q; want debug", got.Log.Level)
|
||
}
|
||
}
|
||
|
||
func TestNewStore_CorruptYAMLFallsBackToDefaults(t *testing.T) {
|
||
dir := t.TempDir()
|
||
// 先寫一個壞 YAML 檔
|
||
path := filepath.Join(dir, "config.yaml")
|
||
if err := os.WriteFile(path, []byte("relay:\n url: [this is not valid yaml"), 0o600); err != nil {
|
||
t.Fatalf("write corrupt: %v", err)
|
||
}
|
||
s, err := NewStore(dir, nil)
|
||
if err != nil {
|
||
t.Fatalf("NewStore should not error on corrupt file; got %v", err)
|
||
}
|
||
if s.Get().Relay.URL != DefaultRelayURL {
|
||
t.Errorf("corrupt file should fall back to defaults; got Relay.URL=%q", s.Get().Relay.URL)
|
||
}
|
||
}
|
||
|
||
func TestNewStore_InvalidValuesFallBackToDefaults(t *testing.T) {
|
||
dir := t.TempDir()
|
||
// 合法 YAML 但值非法
|
||
content := `version: 1
|
||
relay:
|
||
url: http://bad.example.com
|
||
reconnect: maybe
|
||
behavior:
|
||
autostart: false
|
||
log:
|
||
level: trace
|
||
`
|
||
path := filepath.Join(dir, "config.yaml")
|
||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||
t.Fatalf("write: %v", err)
|
||
}
|
||
s, err := NewStore(dir, nil)
|
||
if err != nil {
|
||
t.Fatalf("NewStore: %v", err)
|
||
}
|
||
got := s.Get()
|
||
if got.Relay.URL != DefaultRelayURL || got.Log.Level != DefaultLogLevel {
|
||
t.Errorf("invalid values should fall back to defaults; got %+v", got)
|
||
}
|
||
}
|
||
|
||
func TestSave_RejectsInvalidCfg(t *testing.T) {
|
||
dir := t.TempDir()
|
||
s, err := NewStore(dir, nil)
|
||
if err != nil {
|
||
t.Fatalf("NewStore: %v", err)
|
||
}
|
||
before := s.Get()
|
||
|
||
bad := Default()
|
||
bad.Relay.URL = "http://no-websocket-scheme.example.com"
|
||
if err := s.Save(bad); err == nil {
|
||
t.Error("Save should reject invalid URL")
|
||
}
|
||
// 內部狀態不該被改動
|
||
if got := s.Get(); got.Relay.URL != before.Relay.URL {
|
||
t.Errorf("invalid Save should not mutate internal state; got URL=%q", got.Relay.URL)
|
||
}
|
||
}
|
||
|
||
func TestNewStore_PartialYAMLFilled(t *testing.T) {
|
||
dir := t.TempDir()
|
||
// 只寫 relay.url,其他欄位缺失
|
||
content := `relay:
|
||
url: wss://partial.example.com/tunnel
|
||
`
|
||
path := filepath.Join(dir, "config.yaml")
|
||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||
t.Fatalf("write: %v", err)
|
||
}
|
||
s, err := NewStore(dir, nil)
|
||
if err != nil {
|
||
t.Fatalf("NewStore: %v", err)
|
||
}
|
||
got := s.Get()
|
||
if got.Relay.URL != "wss://partial.example.com/tunnel" {
|
||
t.Errorf("should preserve user URL; got %q", got.Relay.URL)
|
||
}
|
||
if got.Relay.Reconnect != DefaultReconnectMode {
|
||
t.Errorf("should fill missing Reconnect; got %q", got.Relay.Reconnect)
|
||
}
|
||
if got.Log.Level != DefaultLogLevel {
|
||
t.Errorf("should fill missing Log.Level; got %q", got.Log.Level)
|
||
}
|
||
}
|
||
|
||
func TestNewStore_RequiresDataDir(t *testing.T) {
|
||
if _, err := NewStore("", nil); err == nil {
|
||
t.Error("NewStore(\"\") should error")
|
||
}
|
||
}
|