從 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>
265 lines
7.6 KiB
Go
265 lines
7.6 KiB
Go
package main
|
||
|
||
// agent_bindings_test.go — AB8-AB10 binding 層測試。
|
||
//
|
||
// 不啟動 Wails runtime,只驗證 binding 的輸入 / 輸出 / 錯誤處理。
|
||
//
|
||
// 覆蓋項目:
|
||
// - GetAgentSettings / SaveAgentSettings 的 config store 串接
|
||
// - TestConnection 的輸入驗證(不實際上網)
|
||
// - ResetAllSettings 同時清 token + config
|
||
// - ExportLog 產出合法 zip(內含 ring-buffer.txt)
|
||
// - agentConfigToSettings / settingsToAgentConfig 互轉不掉資訊
|
||
|
||
import (
|
||
"archive/zip"
|
||
"io"
|
||
"os"
|
||
"strings"
|
||
"testing"
|
||
|
||
"visiona-agent/internal/agentconfig"
|
||
"visiona-agent/internal/tunnel"
|
||
)
|
||
|
||
// newBindingTestApp 建立一個沒有 Wails runtime 的 App,只帶 AB7-AB10 會用到的元件。
|
||
func newBindingTestApp(t *testing.T) *App {
|
||
t.Helper()
|
||
dir := t.TempDir()
|
||
a := &App{dataDir: dir}
|
||
a.logBuf = NewLogBuffer()
|
||
|
||
store, err := agentconfig.NewStore(dir, nil)
|
||
if err != nil {
|
||
t.Fatalf("NewStore: %v", err)
|
||
}
|
||
a.configStore = store
|
||
|
||
ts, err := tunnel.NewEncryptedFileTokenStore(dir, nil)
|
||
if err != nil {
|
||
t.Fatalf("NewEncryptedFileTokenStore: %v", err)
|
||
}
|
||
a.tokenStore = ts
|
||
return a
|
||
}
|
||
|
||
func TestGetAgentSettings_WithoutStoreReturnsDefaults(t *testing.T) {
|
||
a := &App{}
|
||
got, err := a.GetAgentSettings()
|
||
if err != nil {
|
||
t.Fatalf("GetAgentSettings without store should not error; got %v", err)
|
||
}
|
||
if got.RelayURL != agentconfig.DefaultRelayURL {
|
||
t.Errorf("RelayURL = %q; want %q", got.RelayURL, agentconfig.DefaultRelayURL)
|
||
}
|
||
if got.ReconnectStrategy != ReconnectStrategyAuto {
|
||
t.Errorf("ReconnectStrategy = %q; want auto", got.ReconnectStrategy)
|
||
}
|
||
}
|
||
|
||
func TestSaveAgentSettings_RoundtripThroughConfigStore(t *testing.T) {
|
||
a := newBindingTestApp(t)
|
||
|
||
newSettings := AgentSettings{
|
||
RelayURL: "wss://custom.relay.example.com/tunnel",
|
||
AutoStart: true,
|
||
ReconnectStrategy: ReconnectStrategyManual,
|
||
LogLevel: "debug",
|
||
}
|
||
if err := a.SaveAgentSettings(newSettings); err != nil {
|
||
t.Fatalf("SaveAgentSettings: %v", err)
|
||
}
|
||
got, err := a.GetAgentSettings()
|
||
if err != nil {
|
||
t.Fatalf("GetAgentSettings: %v", err)
|
||
}
|
||
if got != newSettings {
|
||
t.Errorf("Get after Save = %+v; want %+v", got, newSettings)
|
||
}
|
||
}
|
||
|
||
func TestSaveAgentSettings_RejectsInvalidRelayURL(t *testing.T) {
|
||
a := newBindingTestApp(t)
|
||
bad := AgentSettings{
|
||
RelayURL: "http://not-websocket.example.com",
|
||
AutoStart: false,
|
||
ReconnectStrategy: ReconnectStrategyAuto,
|
||
LogLevel: "info",
|
||
}
|
||
if err := a.SaveAgentSettings(bad); err == nil {
|
||
t.Error("SaveAgentSettings should reject http:// URL")
|
||
}
|
||
}
|
||
|
||
func TestSaveAgentSettings_WithoutStoreReturnsNotReady(t *testing.T) {
|
||
a := &App{}
|
||
err := a.SaveAgentSettings(AgentSettings{
|
||
RelayURL: "wss://relay.example.com",
|
||
ReconnectStrategy: ReconnectStrategyAuto,
|
||
LogLevel: "info",
|
||
})
|
||
if err == nil {
|
||
t.Error("SaveAgentSettings without store should error")
|
||
}
|
||
}
|
||
|
||
func TestTestConnection_InputValidation(t *testing.T) {
|
||
a := &App{}
|
||
cases := []struct {
|
||
name string
|
||
url string
|
||
wantOK bool
|
||
wantRea string
|
||
}{
|
||
{"empty", "", false, "empty"},
|
||
{"no scheme", "relay.example.com", false, "ws://"},
|
||
{"http scheme", "http://relay.example.com", false, "ws://"},
|
||
}
|
||
for _, c := range cases {
|
||
t.Run(c.name, func(t *testing.T) {
|
||
got := a.TestConnection(c.url)
|
||
if got.OK != c.wantOK {
|
||
t.Errorf("OK = %v; want %v (reason=%q)", got.OK, c.wantOK, got.Reason)
|
||
}
|
||
if c.wantRea != "" && !strings.Contains(got.Reason, c.wantRea) {
|
||
t.Errorf("Reason %q should mention %q", got.Reason, c.wantRea)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestResetAllSettings_ClearsConfigAndToken(t *testing.T) {
|
||
a := newBindingTestApp(t)
|
||
// 先寫入非預設 settings + 一個 token
|
||
custom := AgentSettings{
|
||
RelayURL: "wss://custom.example.com/tunnel",
|
||
AutoStart: true,
|
||
ReconnectStrategy: ReconnectStrategyManual,
|
||
LogLevel: "debug",
|
||
}
|
||
if err := a.SaveAgentSettings(custom); err != nil {
|
||
t.Fatalf("SaveAgentSettings: %v", err)
|
||
}
|
||
if err := a.tokenStore.Save("vAs_test_token"); err != nil {
|
||
t.Fatalf("Save token: %v", err)
|
||
}
|
||
|
||
if err := a.ResetAllSettings(); err != nil {
|
||
t.Fatalf("ResetAllSettings: %v", err)
|
||
}
|
||
|
||
// Settings 應回到預設
|
||
got, err := a.GetAgentSettings()
|
||
if err != nil {
|
||
t.Fatalf("GetAgentSettings: %v", err)
|
||
}
|
||
if got.RelayURL != agentconfig.DefaultRelayURL {
|
||
t.Errorf("after reset RelayURL = %q; want default %q", got.RelayURL, agentconfig.DefaultRelayURL)
|
||
}
|
||
// Token 應被清除(Unpair 有呼叫 tokenStore.Delete)
|
||
// 但因為 tunnelManager == nil,Unpair 這條路不走;手動驗證 tokenStore
|
||
// 本身在 ResetAllSettings 流程中沒被 Manager 清——這個測試只確認 config 重置。
|
||
// token 清除由 Manager.Unpair() 負責(見 TestResetAllSettings_WithManager)。
|
||
}
|
||
|
||
func TestExportLog_ProducesValidZip(t *testing.T) {
|
||
a := newBindingTestApp(t)
|
||
// 寫幾行 log 到 ring buffer
|
||
for i, line := range []string{"[INFO] first line", "[ERROR] oh no", "plain text"} {
|
||
a.logBuf.Append(LogLine{
|
||
Ts: int64(1700000000000 + i),
|
||
Stream: "test",
|
||
Line: line,
|
||
Level: parseLogLevel(line),
|
||
})
|
||
}
|
||
|
||
path, err := a.ExportLog()
|
||
if err != nil {
|
||
t.Fatalf("ExportLog: %v", err)
|
||
}
|
||
defer os.Remove(path)
|
||
|
||
if !strings.HasSuffix(path, ".zip") {
|
||
t.Errorf("ExportLog path = %q; want .zip suffix", path)
|
||
}
|
||
// 打開 zip 驗證內容
|
||
zr, err := zip.OpenReader(path)
|
||
if err != nil {
|
||
t.Fatalf("open zip: %v", err)
|
||
}
|
||
defer zr.Close()
|
||
|
||
var foundRingBuffer bool
|
||
for _, f := range zr.File {
|
||
if f.Name == "ring-buffer.txt" {
|
||
foundRingBuffer = true
|
||
rc, err := f.Open()
|
||
if err != nil {
|
||
t.Fatalf("open ring-buffer.txt: %v", err)
|
||
}
|
||
data, err := io.ReadAll(rc)
|
||
_ = rc.Close()
|
||
if err != nil {
|
||
t.Fatalf("read ring-buffer.txt: %v", err)
|
||
}
|
||
s := string(data)
|
||
if !strings.Contains(s, "first line") {
|
||
t.Errorf("ring-buffer.txt missing 'first line'; got:\n%s", s)
|
||
}
|
||
if !strings.Contains(s, "[error]") && !strings.Contains(s, "[ERROR]") {
|
||
t.Errorf("ring-buffer.txt should include level; got:\n%s", s)
|
||
}
|
||
}
|
||
}
|
||
if !foundRingBuffer {
|
||
t.Error("zip should contain ring-buffer.txt")
|
||
}
|
||
}
|
||
|
||
func TestExportLog_WithoutLogBufReturnsError(t *testing.T) {
|
||
a := &App{}
|
||
if _, err := a.ExportLog(); err == nil {
|
||
t.Error("ExportLog without logBuf should error")
|
||
}
|
||
}
|
||
|
||
func TestAgentConfigSettings_RoundtripPreservesAllFields(t *testing.T) {
|
||
cases := []AgentSettings{
|
||
{RelayURL: "wss://a.example.com", AutoStart: false, ReconnectStrategy: ReconnectStrategyAuto, LogLevel: "info"},
|
||
{RelayURL: "ws://b.example.com:1234", AutoStart: true, ReconnectStrategy: ReconnectStrategyManual, LogLevel: "debug"},
|
||
{RelayURL: "wss://c.example.com", AutoStart: true, ReconnectStrategy: ReconnectStrategyAuto, LogLevel: "warn"},
|
||
{RelayURL: "wss://d.example.com", AutoStart: false, ReconnectStrategy: ReconnectStrategyManual, LogLevel: "error"},
|
||
}
|
||
for _, in := range cases {
|
||
cfg := settingsToAgentConfig(in)
|
||
out := agentConfigToSettings(cfg)
|
||
if out != in {
|
||
t.Errorf("roundtrip:\n in = %+v\n out = %+v", in, out)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestGetRecentLogs_RespectsLimit(t *testing.T) {
|
||
a := newBindingTestApp(t)
|
||
for i := 0; i < 10; i++ {
|
||
a.logBuf.Append(LogLine{Ts: int64(i), Line: "line"})
|
||
}
|
||
got := a.GetRecentLogs(3)
|
||
if len(got) != 3 {
|
||
t.Errorf("GetRecentLogs(3) len = %d; want 3", len(got))
|
||
}
|
||
// 應該是最新 3 筆(index 7, 8, 9)
|
||
if got[0].Ts != 7 || got[2].Ts != 9 {
|
||
t.Errorf("GetRecentLogs(3) ts = [%d, _, %d]; want [7, _, 9]", got[0].Ts, got[2].Ts)
|
||
}
|
||
}
|
||
|
||
func TestGetRecentLogs_WithoutBufferReturnsEmpty(t *testing.T) {
|
||
a := &App{}
|
||
got := a.GetRecentLogs(10)
|
||
if len(got) != 0 {
|
||
t.Errorf("GetRecentLogs without buffer = %d items; want 0", len(got))
|
||
}
|
||
}
|