visionA/local-agent/frontend/src/lib/agent-api.test.ts
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

315 lines
10 KiB
TypeScript
Raw 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.

/**
* agent-api 單元測試AF6
*
* 驗證重點:
* 1. Wails runtime 偵測window.go 不存在時走 mock、存在時呼叫 real binding
* 2. Go DTO → TS 介面的轉換(特別是 int64 ms → ISO string
* 3. Go error 字串 → PairErrorCode 的解析
* 4. Event 訂閱wails 環境下 EventsOn 被呼叫、mock 環境下為 no-op
*
* 不測試:
* - Wails 內部序列化行為(由 Wails runtime 保證)
* - 實際網路 handshake那是 Go 側責任)
*/
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ConnectionSnapshot } from "@/types/agent";
import {
__isWailsRuntimeForTest,
__resetMockForTest,
agentAPI,
onConnectionStatus,
} from "./agent-api";
/* -------------------------------------------------------------------------- */
/* 測試工具:安裝假的 window.go / window.runtime */
/* -------------------------------------------------------------------------- */
interface FakeBinding {
GetConnectionStatus: ReturnType<typeof vi.fn>;
Pair: ReturnType<typeof vi.fn>;
Unpair: ReturnType<typeof vi.fn>;
Reconnect: ReturnType<typeof vi.fn>;
Disconnect: ReturnType<typeof vi.fn>;
GetAgentSettings: ReturnType<typeof vi.fn>;
SaveAgentSettings: ReturnType<typeof vi.fn>;
TestConnection: ReturnType<typeof vi.fn>;
ResetAllSettings: ReturnType<typeof vi.fn>;
GetRecentLogs: ReturnType<typeof vi.fn>;
ExportLog: ReturnType<typeof vi.fn>;
}
function installFakeWails(): {
binding: FakeBinding;
eventsOn: ReturnType<typeof vi.fn>;
eventsOff: ReturnType<typeof vi.fn>;
browserOpenURL: ReturnType<typeof vi.fn>;
} {
const binding: FakeBinding = {
GetConnectionStatus: vi.fn(),
Pair: vi.fn(),
Unpair: vi.fn(),
Reconnect: vi.fn(),
Disconnect: vi.fn(),
GetAgentSettings: vi.fn(),
SaveAgentSettings: vi.fn(),
TestConnection: vi.fn(),
ResetAllSettings: vi.fn(),
GetRecentLogs: vi.fn(),
ExportLog: vi.fn(),
};
const eventsOn = vi.fn(() => vi.fn()); // EventsOn 回傳取消訂閱函式
const eventsOff = vi.fn();
const browserOpenURL = vi.fn();
// jsdom 環境:把假的 go / runtime 直接塞到 window
(window as unknown as { go: unknown }).go = { main: { App: binding } };
(window as unknown as { runtime: unknown }).runtime = {
EventsOn: eventsOn,
EventsOff: eventsOff,
BrowserOpenURL: browserOpenURL,
};
return { binding, eventsOn, eventsOff, browserOpenURL };
}
function uninstallFakeWails() {
delete (window as unknown as { go?: unknown }).go;
delete (window as unknown as { runtime?: unknown }).runtime;
}
/* -------------------------------------------------------------------------- */
/* 測試Mock 路徑window.go 不存在) */
/* -------------------------------------------------------------------------- */
describe("agentAPI — Mock 路徑(瀏覽器 dev 模式)", () => {
beforeEach(() => {
uninstallFakeWails();
__resetMockForTest();
});
it("偵測到無 Wails runtime", () => {
expect(__isWailsRuntimeForTest()).toBe(false);
});
it("getConnectionStatus 回 notPaired 預設值", async () => {
const snap = await agentAPI.getConnectionStatus();
expect(snap.state).toBe("notPaired");
expect(snap.relayUrl).toContain("wss://");
});
it("pair 接受合法 token 後 resolve", async () => {
const validToken = "vAc_" + "a".repeat(32);
await expect(agentAPI.pair(validToken)).resolves.toBeUndefined();
});
it("pair 收到格式錯誤的 token 時 reject 帶 PairError", async () => {
await expect(agentAPI.pair("not-a-valid-token")).rejects.toMatchObject({
code: "token_invalid",
});
});
it("testConnection 驗證 URL 協議", async () => {
const ok = await agentAPI.testConnection("wss://relay.example.com");
expect(ok.ok).toBe(true);
expect(ok.latencyMs).toBeTypeOf("number");
const bad = await agentAPI.testConnection("http://not-ws");
expect(bad.ok).toBe(false);
expect(bad.reason).toContain("ws://");
});
it("saveAgentSettings / getAgentSettings 能來回讀寫", async () => {
const patched = {
relayUrl: "wss://custom.test",
autoStart: true,
reconnectStrategy: "manual" as const,
logLevel: "debug" as const,
};
await agentAPI.saveAgentSettings(patched);
const read = await agentAPI.getAgentSettings();
expect(read).toEqual(patched);
});
it("getRecentLogs 回空陣列Phase 0 mock", async () => {
const logs = await agentAPI.getRecentLogs(10);
expect(logs).toEqual([]);
});
it("exportLog 回 mock path", async () => {
const path = await agentAPI.exportLog();
expect(path).toMatch(/\.zip$/);
});
it("openURLmock 環境)退到 window.opennoopener noreferrer", () => {
// Fix-F3mock 環境沒 Wails runtime → fallback window.open
const spy = vi.spyOn(window, "open").mockImplementation(() => null);
agentAPI.openURL("https://example.com");
expect(spy).toHaveBeenCalledWith(
"https://example.com",
"_blank",
"noopener,noreferrer",
);
spy.mockRestore();
});
it("onConnectionStatus 回 no-op unsubscribecallback 永遠不被呼叫", () => {
const cb = vi.fn();
const off = onConnectionStatus(cb);
expect(typeof off).toBe("function");
expect(() => off()).not.toThrow();
expect(cb).not.toHaveBeenCalled();
});
});
/* -------------------------------------------------------------------------- */
/* 測試Real 路徑window.go 存在) */
/* -------------------------------------------------------------------------- */
describe("agentAPI — Real 路徑Wails runtime 模擬)", () => {
let binding: FakeBinding;
let eventsOn: ReturnType<typeof vi.fn>;
let browserOpenURL: ReturnType<typeof vi.fn>;
beforeEach(() => {
const fake = installFakeWails();
binding = fake.binding;
eventsOn = fake.eventsOn;
browserOpenURL = fake.browserOpenURL;
});
afterEach(() => {
uninstallFakeWails();
});
it("偵測到 Wails runtime", () => {
expect(__isWailsRuntimeForTest()).toBe(true);
});
it("getConnectionStatus 呼叫 Go binding 並轉 ms → ISO", async () => {
const goMs = 1_700_000_000_000;
binding.GetConnectionStatus.mockResolvedValue({
state: "online",
relayUrl: "wss://relay.test",
connectedSince: goMs,
sessionTokenPreview: "vAs_abc ··· def",
});
const snap: ConnectionSnapshot = await agentAPI.getConnectionStatus();
expect(binding.GetConnectionStatus).toHaveBeenCalledOnce();
expect(snap.state).toBe("online");
expect(snap.connectedSince).toBe(new Date(goMs).toISOString());
expect(snap.sessionTokenPreview).toBe("vAs_abc ··· def");
});
it("getConnectionStatus 把未知 state 收斂到 error", async () => {
binding.GetConnectionStatus.mockResolvedValue({
state: "weird-unknown-value",
relayUrl: "wss://relay.test",
});
const snap = await agentAPI.getConnectionStatus();
expect(snap.state).toBe("error");
});
it("getConnectionStatus 不帶 connectedSince 時欄位省略", async () => {
binding.GetConnectionStatus.mockResolvedValue({
state: "notPaired",
relayUrl: "wss://relay.test",
});
const snap = await agentAPI.getConnectionStatus();
expect(snap.connectedSince).toBeUndefined();
});
it("pair 成功時 resolve", async () => {
binding.Pair.mockResolvedValue(undefined);
await expect(agentAPI.pair("vAc_abc")).resolves.toBeUndefined();
expect(binding.Pair).toHaveBeenCalledWith("vAc_abc");
});
it.each([
["pairing token expired", "token_expired"],
["pairing token already used", "token_used"],
["pairing token revoked", "token_revoked"],
["pairing token invalid", "token_invalid"],
["invalid pairing token format (expected vAc_ + 32 hex)", "token_invalid"],
["websocket dial: connection refused", "relay_unreachable"],
["network timeout", "network_error"],
["something else entirely", "unknown"],
])("pair Go error %q → code %q", async (goMsg, expectedCode) => {
binding.Pair.mockRejectedValue(new Error(goMsg));
try {
await agentAPI.pair("vAc_xxx");
throw new Error("should have rejected");
} catch (err) {
expect(err).toMatchObject({ code: expectedCode });
}
});
it("testConnection 透傳 Go 結果", async () => {
binding.TestConnection.mockResolvedValue({ ok: true, latencyMs: 30 });
const r = await agentAPI.testConnection("wss://test");
expect(r).toEqual({ ok: true, latencyMs: 30, reason: undefined });
expect(binding.TestConnection).toHaveBeenCalledWith("wss://test");
});
it("saveAgentSettings 透傳整包 settings 給 Go", async () => {
binding.SaveAgentSettings.mockResolvedValue(undefined);
const patch = {
relayUrl: "wss://new",
autoStart: true,
reconnectStrategy: "manual" as const,
logLevel: "debug" as const,
};
await agentAPI.saveAgentSettings(patch);
expect(binding.SaveAgentSettings).toHaveBeenCalledWith(patch);
});
it("exportLog 透傳 Go 回的 path", async () => {
binding.ExportLog.mockResolvedValue("/tmp/export.zip");
const path = await agentAPI.exportLog();
expect(path).toBe("/tmp/export.zip");
});
it("openURLWails 環境)呼叫 runtime.BrowserOpenURL", () => {
// Fix-F3Wails 環境下走 BrowserOpenURL不該打到 window.open
const winOpenSpy = vi.spyOn(window, "open").mockImplementation(() => null);
agentAPI.openURL("https://docs.visionA.cloud/agent");
expect(browserOpenURL).toHaveBeenCalledWith("https://docs.visionA.cloud/agent");
expect(winOpenSpy).not.toHaveBeenCalled();
winOpenSpy.mockRestore();
});
it("onConnectionStatus 訂閱 Wails event", () => {
const cb = vi.fn();
onConnectionStatus(cb);
expect(eventsOn).toHaveBeenCalledWith(
"connection:status",
expect.any(Function),
);
});
it("onConnectionStatus 收到 Go payload 會轉成 TS snapshot 後呼 callback", () => {
const cb = vi.fn();
onConnectionStatus(cb);
const wailsHandler = eventsOn.mock.calls[0][1] as (
...data: unknown[]
) => void;
// 模擬 Wails 推一個 payloadspread 參數進來)
wailsHandler({
state: "online",
relayUrl: "wss://r",
connectedSince: 1_700_000_000_000,
});
expect(cb).toHaveBeenCalledOnce();
const snap = cb.mock.calls[0][0] as ConnectionSnapshot;
expect(snap.state).toBe("online");
expect(snap.connectedSince).toBe(
new Date(1_700_000_000_000).toISOString(),
);
});
});