從 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>
152 lines
3.5 KiB
Go
152 lines
3.5 KiB
Go
package main
|
|
|
|
// log_buffer_test.go — M8-4 ring buffer + level parse + rate limit 的單元測試
|
|
|
|
import (
|
|
"fmt"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestLogBuffer_AppendAndSnapshot(t *testing.T) {
|
|
b := NewLogBuffer()
|
|
if got := b.Size(); got != 0 {
|
|
t.Fatalf("initial size=%d, want 0", got)
|
|
}
|
|
|
|
// 先塞 3 行
|
|
for i := 0; i < 3; i++ {
|
|
b.Append(LogLine{
|
|
Ts: time.Now().UnixMilli(),
|
|
Stream: "stdout",
|
|
Line: fmt.Sprintf("line-%d", i),
|
|
})
|
|
}
|
|
if got := b.Size(); got != 3 {
|
|
t.Fatalf("size after 3 appends=%d, want 3", got)
|
|
}
|
|
|
|
// Snapshot(0) 應回全部
|
|
all := b.Snapshot(0)
|
|
if len(all) != 3 {
|
|
t.Fatalf("snapshot(0) len=%d, want 3", len(all))
|
|
}
|
|
for i := 0; i < 3; i++ {
|
|
if all[i].Line != fmt.Sprintf("line-%d", i) {
|
|
t.Fatalf("snapshot[%d].Line=%q", i, all[i].Line)
|
|
}
|
|
}
|
|
|
|
// Snapshot(2) 應回最後 2 行
|
|
last2 := b.Snapshot(2)
|
|
if len(last2) != 2 {
|
|
t.Fatalf("snapshot(2) len=%d", len(last2))
|
|
}
|
|
if last2[0].Line != "line-1" || last2[1].Line != "line-2" {
|
|
t.Fatalf("snapshot(2) 內容錯誤: %+v", last2)
|
|
}
|
|
}
|
|
|
|
func TestLogBuffer_WrapAround(t *testing.T) {
|
|
b := NewLogBuffer()
|
|
// 塞超過 cap 100 行
|
|
total := logBufferCap + 100
|
|
for i := 0; i < total; i++ {
|
|
b.Append(LogLine{Line: fmt.Sprintf("line-%d", i)})
|
|
}
|
|
if got := b.Size(); got != logBufferCap {
|
|
t.Fatalf("size=%d, want %d", got, logBufferCap)
|
|
}
|
|
if got := b.Dropped(); got != 100 {
|
|
t.Fatalf("dropped=%d, want 100", got)
|
|
}
|
|
|
|
// 取最後 3 行,應該是 line-{total-3} ... line-{total-1}
|
|
last3 := b.Snapshot(3)
|
|
if len(last3) != 3 {
|
|
t.Fatalf("snapshot len=%d, want 3", len(last3))
|
|
}
|
|
expected := []string{
|
|
fmt.Sprintf("line-%d", total-3),
|
|
fmt.Sprintf("line-%d", total-2),
|
|
fmt.Sprintf("line-%d", total-1),
|
|
}
|
|
for i, e := range expected {
|
|
if last3[i].Line != e {
|
|
t.Fatalf("last3[%d]=%q, want %q", i, last3[i].Line, e)
|
|
}
|
|
}
|
|
|
|
// 取全部應該是 line-100 ... line-2099
|
|
all := b.Snapshot(0)
|
|
if len(all) != logBufferCap {
|
|
t.Fatalf("all len=%d", len(all))
|
|
}
|
|
if all[0].Line != fmt.Sprintf("line-%d", total-logBufferCap) {
|
|
t.Fatalf("all[0]=%q", all[0].Line)
|
|
}
|
|
if all[len(all)-1].Line != fmt.Sprintf("line-%d", total-1) {
|
|
t.Fatalf("all[last]=%q", all[len(all)-1].Line)
|
|
}
|
|
}
|
|
|
|
func TestLogBuffer_Reset(t *testing.T) {
|
|
b := NewLogBuffer()
|
|
for i := 0; i < 10; i++ {
|
|
b.Append(LogLine{Line: fmt.Sprintf("x-%d", i)})
|
|
}
|
|
b.Reset()
|
|
if got := b.Size(); got != 0 {
|
|
t.Fatalf("size after reset=%d, want 0", got)
|
|
}
|
|
snap := b.Snapshot(0)
|
|
if len(snap) != 0 {
|
|
t.Fatalf("snapshot after reset len=%d, want 0", len(snap))
|
|
}
|
|
}
|
|
|
|
func TestLogBuffer_RateLimit(t *testing.T) {
|
|
b := NewLogBuffer()
|
|
|
|
// 一秒內前 logRateLimitBurst 次 ShouldEmit 都應為 true
|
|
allowed := 0
|
|
denied := 0
|
|
for i := 0; i < logRateLimitBurst+50; i++ {
|
|
if b.ShouldEmit() {
|
|
allowed++
|
|
} else {
|
|
denied++
|
|
}
|
|
}
|
|
if allowed != logRateLimitBurst {
|
|
t.Fatalf("allowed=%d, want %d", allowed, logRateLimitBurst)
|
|
}
|
|
if denied != 50 {
|
|
t.Fatalf("denied=%d, want 50", denied)
|
|
}
|
|
}
|
|
|
|
func TestParseLogLevel(t *testing.T) {
|
|
cases := []struct {
|
|
line string
|
|
level string
|
|
}{
|
|
{"2026/04/14 14:23:01 [INFO] server started", "info"},
|
|
{"[WARN] something odd", "warn"},
|
|
{"[ERROR] python died", "error"},
|
|
{"[DEBUG] trace", "debug"},
|
|
{"INFO: message", "info"},
|
|
{"[GIN] 200 | 2.1ms | GET /api/system/info", "info"},
|
|
{"[GIN] 404 | 0.8ms | GET /missing", "warn"},
|
|
{"[GIN] 500 | 12ms | POST /api/boom", "error"},
|
|
{"random text line", ""},
|
|
{"", ""},
|
|
}
|
|
for _, c := range cases {
|
|
got := parseLogLevel(c.line)
|
|
if got != c.level {
|
|
t.Errorf("parseLogLevel(%q)=%q, want %q", c.line, got, c.level)
|
|
}
|
|
}
|
|
}
|