Files
HomeAgent/internal/plugins/webui/handler_test.go
HomeAgent Agent c0e9dc1818 fix(webui): 聊天记录不再"每次都发完整记录",并修掉视口跳顶
两个都是你指出的症状,都定位到了具体代码路径。

① 「每次都发完整聊天记录」
   a) API 缺省值错了:/api/v1/chat/history 的 limit 缺省是 0 = **不限制**,
      于是任何不带 limit 的调用每次都拿到整段记录。实测(126 条):
      不带 limit 635,297 字节;现在默认只回一页 180,668 字节。
      显式 limit=0 仍可整取(逃生口)。WebUI/GUI 本来都带 limit,不受影响。
   b) 前端 30s 轮询(以及每次 SSE 报错)都直接拉一页 40 条:
      浏览器实测单次 180,813 字节。现在先做"尾巴探测"(limit=1,362 字节),
      尾巴一致就直接跳过;不一致才拉整页。

② 「聊天记录会跳到顶部」——两条会导致视口丢失的路径都堵上
   a) syncChatFromHistory 在"找不到重合点"时直接 `state.messages = serverMsgs`:
      服务端只回一页,而本地可能已经向上翻了好几页;一覆盖,容器立刻变矮,
      视口被夹回顶部,用户翻过的旧消息也凭空消失。现在只在服务端页**不短于**本地时
      才整体替换。
   b) renderChat 全量重建 innerHTML 后,仅在粘底时滚到底;非粘底(用户正在向上读)
      时位置没人管。改为重建前记住 scrollTop、非粘底时原样还回去。

浏览器实测(CDP 驱动真实页面,126 条历史):
  * 15s/30s 定时器跑满 40s:聊天区滚动位置 **0 px 变化**,未跳顶;
  * 期间 chat/history 请求:limit=1 × 2(各 362 字节)+ 首屏 limit=40 一次;
  * 控制台无报错;新增消息后轮询仍能正确并进来(尾巴探测→拉整页→合并)。

测试:新增 TestChatHistoryDefaultIsPaged(默认一页 / has_more / limit=0 整取 / 显式分页)。
顺带修测试串味:迁移用例往 os.TempDir() 写共享历史文件,会让其它用例的
NewHandler 加载到脏历史(表现为条数多 1);现在各用例用自己的临时文件。
2026-09-14 08:06:16 +08:00

1390 lines
43 KiB
Go
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.

package webui
import (
"context"
"encoding/json"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
agentCore "gitcode.com/JianFeeeee/HomeAgent/internal/agent/core"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
internalConfig "gitcode.com/JianFeeeee/HomeAgent/internal/config"
"gitcode.com/JianFeeeee/HomeAgent/internal/events"
"gitcode.com/JianFeeeee/HomeAgent/internal/knowledge"
"gitcode.com/JianFeeeee/HomeAgent/internal/memory"
sdk "gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
"gitcode.com/JianFeeeee/HomeAgent/internal/supervisor"
"gitcode.com/JianFeeeee/HomeAgent/internal/tracker"
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
)
func testSDK(cfg sdk.SDKConfig) *sdk.PluginSDK {
if cfg.EventBus == nil {
cfg.EventBus = events.NewBus()
}
return sdk.New("webui", cfg)
}
func newTestHandler(t *testing.T) (*Handler, *supervisor.Daemon) {
t.Helper()
cfg := &types.Config{
Daemon: types.DaemonConfig{
CheckInterval: time.Minute,
HeartbeatInterval: 30 * time.Second,
},
}
sup := supervisor.New(cfg)
sup.Start()
s := testSDK(sdk.SDKConfig{
Supervisor: supervisor.NewSDKAdapter(sup),
Config: sdk.NewConfig(cfg),
})
return NewHandler(s), sup
}
func seedWebUIConfig(cfgReg *internalConfig.ConfigRegistry) {
webuiCfg := cfgReg.PluginConfig("webui")
webuiCfg.RegisterDef(internalConfig.ConfigDef{Key: "api_key", Default: ""})
webuiCfg.RegisterDef(internalConfig.ConfigDef{Key: "username", Default: "admin"})
webuiCfg.RegisterDef(internalConfig.ConfigDef{Key: "password", Default: ""})
webuiCfg.RegisterDef(internalConfig.ConfigDef{Key: "session_ttl_hours", Default: "24"})
webuiCfg.Set("api_key", "test-api-key")
webuiCfg.Set("username", "admin")
webuiCfg.Set("password", "secret-pass")
webuiCfg.Set("session_ttl_hours", "24")
}
func TestAuthMiddleware(t *testing.T) {
cfgReg := internalConfig.NewConfigRegistry("")
seedWebUIConfig(cfgReg)
sup := supervisor.New(&types.Config{
Daemon: types.DaemonConfig{
CheckInterval: time.Minute,
HeartbeatInterval: 30 * time.Second,
},
})
sup.Start()
defer sup.Shutdown()
s := testSDK(sdk.SDKConfig{
Supervisor: supervisor.NewSDKAdapter(sup),
Settings: sdk.NewSettings("webui", cfgReg),
Config: sdk.NewConfig(&types.Config{}),
})
h := NewHandler(s)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
t.Run("api_requires_auth", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", w.Code)
}
})
t.Run("api_key_allows_access", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil)
req.Header.Set("X-API-Key", "test-api-key")
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
})
t.Run("login_sets_session_cookie", func(t *testing.T) {
body := `{"username":"admin","password":"secret-pass"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/login", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
if len(w.Result().Cookies()) == 0 {
t.Fatal("expected session cookie")
}
})
t.Run("root_redirects_to_login_without_session", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != http.StatusFound {
t.Fatalf("expected 302, got %d", w.Code)
}
})
}
func TestHandleStatus(t *testing.T) {
h, sup := newTestHandler(t)
defer sup.Shutdown()
req := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil)
w := httptest.NewRecorder()
h.handleStatus(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
var resp map[string]interface{}
json.NewDecoder(w.Body).Decode(&resp)
if resp["status"] != "running" {
t.Errorf("expected running, got %v", resp["status"])
}
}
func TestHandleStatusMethodNotAllowed(t *testing.T) {
h, sup := newTestHandler(t)
defer sup.Shutdown()
req := httptest.NewRequest(http.MethodPost, "/api/v1/status", nil)
w := httptest.NewRecorder()
h.handleStatus(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("expected 405, got %d", w.Code)
}
}
func TestHandleAgents(t *testing.T) {
h, sup := newTestHandler(t)
defer sup.Shutdown()
sup.RegisterAgent("test_agent")
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents", nil)
w := httptest.NewRecorder()
h.handleAgents(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
var resp map[string]interface{}
json.NewDecoder(w.Body).Decode(&resp)
agents, ok := resp["agents"].([]interface{})
if !ok || len(agents) == 0 {
t.Error("expected agents list")
}
}
func TestHandleAgentByID(t *testing.T) {
h, sup := newTestHandler(t)
defer sup.Shutdown()
sup.RegisterAgent("my_agent")
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/my_agent", nil)
w := httptest.NewRecorder()
h.handleAgentByID(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
var resp map[string]interface{}
json.NewDecoder(w.Body).Decode(&resp)
if resp["id"] != "my_agent" {
t.Errorf("expected my_agent, got %v", resp["id"])
}
}
func TestHandleAgentByIDNotFound(t *testing.T) {
h, sup := newTestHandler(t)
defer sup.Shutdown()
req := httptest.NewRequest(http.MethodGet, "/api/v1/agents/nonexistent", nil)
w := httptest.NewRecorder()
h.handleAgentByID(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("expected 404, got %d", w.Code)
}
}
func TestHandleKnowledgeSearch(t *testing.T) {
ks := knowledge.NewStore(t.TempDir())
ks.Start()
ks.Add("test_doc", "this is test content for searching")
cfg := &types.Config{
Daemon: types.DaemonConfig{
CheckInterval: time.Minute,
HeartbeatInterval: 30 * time.Second,
},
}
sup := supervisor.New(cfg)
sup.Start()
defer sup.Shutdown()
s := testSDK(sdk.SDKConfig{
Supervisor: supervisor.NewSDKAdapter(sup),
Knowledge: sdk.NewKnowledge(ks),
Config: sdk.NewConfig(cfg),
})
h := NewHandler(s)
req := httptest.NewRequest(http.MethodGet, "/api/v1/knowledge?q=test", nil)
w := httptest.NewRecorder()
h.handleKnowledge(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
var resp map[string]interface{}
json.NewDecoder(w.Body).Decode(&resp)
results, ok := resp["results"].([]interface{})
if !ok || len(results) == 0 {
t.Error("expected search results")
}
}
func TestHandleKnowledgeCreate(t *testing.T) {
ks := knowledge.NewStore(t.TempDir())
ks.Start()
cfg := &types.Config{
Daemon: types.DaemonConfig{
CheckInterval: time.Minute,
HeartbeatInterval: 30 * time.Second,
},
}
sup := supervisor.New(cfg)
sup.Start()
defer sup.Shutdown()
s := testSDK(sdk.SDKConfig{
Supervisor: supervisor.NewSDKAdapter(sup),
Knowledge: sdk.NewKnowledge(ks),
Config: sdk.NewConfig(cfg),
})
h := NewHandler(s)
body := `{"name":"new_doc","content":"fresh content"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/knowledge", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handleKnowledge(w, req)
if w.Code != http.StatusCreated {
t.Errorf("expected 201, got %d", w.Code)
}
}
func TestHandleKnowledgeUnavailable(t *testing.T) {
h, sup := newTestHandler(t)
defer sup.Shutdown()
req := httptest.NewRequest(http.MethodGet, "/api/v1/knowledge?q=test", nil)
w := httptest.NewRecorder()
h.handleKnowledge(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Errorf("expected 503, got %d", w.Code)
}
}
func TestHandleMemoryUnavailable(t *testing.T) {
h, sup := newTestHandler(t)
defer sup.Shutdown()
req := httptest.NewRequest(http.MethodGet, "/api/v1/memory?q=test", nil)
w := httptest.NewRecorder()
h.handleMemory(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Errorf("expected 503, got %d", w.Code)
}
}
func TestHandleTrackerNotAvailable(t *testing.T) {
h, sup := newTestHandler(t)
defer sup.Shutdown()
req := httptest.NewRequest(http.MethodGet, "/api/v1/tracker", nil)
w := httptest.NewRecorder()
h.handleTracker(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Errorf("expected 503, got %d", w.Code)
}
}
func TestHandleTrackerStats(t *testing.T) {
tr := tracker.NewTracker(t.TempDir(), t.TempDir())
cfg := &types.Config{
Daemon: types.DaemonConfig{
CheckInterval: time.Minute,
HeartbeatInterval: 30 * time.Second,
},
}
sup := supervisor.New(cfg)
sup.Start()
defer sup.Shutdown()
s := testSDK(sdk.SDKConfig{
Supervisor: supervisor.NewSDKAdapter(sup),
Tracker: tr,
Config: sdk.NewConfig(cfg),
})
h := NewHandler(s)
req := httptest.NewRequest(http.MethodGet, "/api/v1/tracker", nil)
w := httptest.NewRecorder()
h.handleTracker(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
}
func TestHandleOpenAICompletionsNoMessages(t *testing.T) {
h, sup := newTestHandler(t)
defer sup.Shutdown()
body := `{"model":"test"}`
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handleOpenAICompletions(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestHandleOpenAICompletionsLastMsgNotUser(t *testing.T) {
h, sup := newTestHandler(t)
defer sup.Shutdown()
body := `{"messages":[{"role":"assistant","content":"hi"}]}`
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handleOpenAICompletions(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected 400, got %d", w.Code)
}
}
func TestHandleOpenAICompletionsMethodNotAllowed(t *testing.T) {
h, sup := newTestHandler(t)
defer sup.Shutdown()
req := httptest.NewRequest(http.MethodGet, "/v1/chat/completions", nil)
w := httptest.NewRecorder()
h.handleOpenAICompletions(w, req)
if w.Code != http.StatusMethodNotAllowed {
t.Errorf("expected 405, got %d", w.Code)
}
}
func TestHandleStaticServesHTML(t *testing.T) {
h, sup := newTestHandler(t)
defer sup.Shutdown()
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
h.handleStatic(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
if !strings.Contains(w.Body.String(), "HomeAgent Dashboard") {
t.Error("expected dashboard HTML")
}
}
func TestHandleStaticNotFound(t *testing.T) {
h, sup := newTestHandler(t)
defer sup.Shutdown()
req := httptest.NewRequest(http.MethodGet, "/nonexistent", nil)
w := httptest.NewRecorder()
h.handleStatic(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("expected 404, got %d", w.Code)
}
}
func TestHandleConfigGet(t *testing.T) {
h, sup := newTestHandler(t)
defer sup.Shutdown()
req := httptest.NewRequest(http.MethodGet, "/api/v1/config", nil)
w := httptest.NewRecorder()
h.handleConfig(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected 200, got %d", w.Code)
}
}
func TestRegisterRoutes(t *testing.T) {
cfgReg := internalConfig.NewConfigRegistry("")
seedWebUIConfig(cfgReg)
sup := supervisor.New(&types.Config{
Daemon: types.DaemonConfig{
CheckInterval: time.Minute,
HeartbeatInterval: 30 * time.Second,
},
})
sup.Start()
defer sup.Shutdown()
s := testSDK(sdk.SDKConfig{
Supervisor: supervisor.NewSDKAdapter(sup),
Settings: sdk.NewSettings("webui", cfgReg),
Config: sdk.NewConfig(&types.Config{}),
})
h := NewHandler(s)
mux := http.NewServeMux()
h.RegisterRoutes(mux)
tests := []struct {
path string
method string
code int
}{
{"/api/v1/status", http.MethodGet, http.StatusOK},
{"/api/v1/agents", http.MethodGet, http.StatusOK},
{"/api/v1/config", http.MethodGet, http.StatusOK},
{"/api/v1/network", http.MethodGet, http.StatusOK},
{"/", http.MethodGet, http.StatusFound},
{"/api/v1/memory", http.MethodGet, http.StatusServiceUnavailable},
{"/api/v1/knowledge", http.MethodGet, http.StatusServiceUnavailable},
{"/api/v1/tracker", http.MethodGet, http.StatusServiceUnavailable},
{"/api/v1/adapters", http.MethodGet, http.StatusServiceUnavailable},
}
for _, tt := range tests {
req := httptest.NewRequest(tt.method, tt.path, nil)
if strings.HasPrefix(tt.path, "/api/v1/") {
req.Header.Set("X-API-Key", "test-api-key")
}
w := httptest.NewRecorder()
mux.ServeHTTP(w, req)
if w.Code != tt.code {
t.Errorf("%s %s: expected %d, got %d", tt.method, tt.path, tt.code, w.Code)
}
}
}
func TestHandleAdapterByIDNotFound(t *testing.T) {
h, sup := newTestHandler(t)
defer sup.Shutdown()
req := httptest.NewRequest(http.MethodGet, "/api/v1/adapters/nonexistent", nil)
w := httptest.NewRecorder()
h.handleAdapterByID(w, req)
// Returns 503 when lua VM is not available
if w.Code != http.StatusServiceUnavailable {
t.Errorf("expected 503, got %d", w.Code)
}
}
func TestHandleAdaptersUnavailable(t *testing.T) {
h, sup := newTestHandler(t)
defer sup.Shutdown()
req := httptest.NewRequest(http.MethodGet, "/api/v1/adapters", nil)
w := httptest.NewRecorder()
h.handleAdapters(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Errorf("expected 503, got %d", w.Code)
}
}
// === 全流程集成测试ConfigRegistry → WebUI → HTTP API ===
func TestSettingsAPIFlow(t *testing.T) {
cfgReg := internalConfig.NewConfigRegistry("")
cfgReg.Register("core.llm.model", "deepseek-v4-flash")
cfgReg.Register("core.llm.base_url", "https://api.deepseek.com")
cfgReg.Register("webui.listen_addr", ":8080")
cfgReg.Register("plugin.qq.access_token", "secret123")
sup := supervisor.New(&types.Config{
Daemon: types.DaemonConfig{
CheckInterval: time.Minute,
HeartbeatInterval: 30 * time.Second,
},
})
sup.Start()
defer sup.Shutdown()
s := testSDK(sdk.SDKConfig{
Supervisor: supervisor.NewSDKAdapter(sup),
Settings: sdk.NewSettings("webui", cfgReg),
Config: sdk.NewConfig(&types.Config{}),
})
h := NewHandler(s)
t.Run("GET_settings_lists_keys_and_plugins", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/settings", nil)
w := httptest.NewRecorder()
h.handleSettings(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
settings, ok := resp["settings"].(map[string]interface{})
if !ok {
t.Fatal("settings not a map")
}
if v, _ := settings["core.llm.model"].(string); v != "deepseek-v4-flash" {
t.Fatalf("expected deepseek-v4-flash, got %v", settings["core.llm.model"])
}
plugins, ok := resp["plugins"].([]interface{})
if !ok || len(plugins) == 0 {
t.Fatal("expected plugins list")
}
if plugins[0] != "core" {
t.Fatalf("expected first plugin 'core', got %v", plugins[0])
}
})
t.Run("GET_settings_with_prefix", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/v1/settings?prefix=webui", nil)
w := httptest.NewRecorder()
h.handleSettings(w, req)
var resp map[string]interface{}
json.NewDecoder(w.Body).Decode(&resp)
settings := resp["settings"].(map[string]interface{})
if _, ok := settings["webui.listen_addr"]; !ok {
t.Fatal("expected webui.listen_addr in filtered results")
}
if _, ok := settings["core.llm.model"]; ok {
t.Fatal("core.llm.model should not be in webui filtered results")
}
})
t.Run("PUT_settings_updates_value", func(t *testing.T) {
body := `{"key":"core.llm.model","value":"gpt-4"}`
req := httptest.NewRequest(http.MethodPut, "/api/v1/settings", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handleSettings(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
val, err := cfgReg.Get("core.llm.model")
if err != nil {
t.Fatalf("Get error: %v", err)
}
if v, _ := val.(string); v != "gpt-4" {
t.Fatalf("expected gpt-4, got %v", val)
}
})
t.Run("PUT_settings_invalid_body", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPut, "/api/v1/settings", strings.NewReader("not json"))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handleSettings(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
})
t.Run("handleStatic_returns_webui_html", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
h.handleStatic(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
body, _ := io.ReadAll(w.Body)
html := string(body)
if !strings.Contains(html, "settings-layout") {
t.Fatal("HTML should contain settings-layout class")
}
if !strings.Contains(html, "settings-tabs") {
t.Fatal("HTML should contain settings-tabs class")
}
if !strings.Contains(html, "saveSetting") {
t.Fatal("HTML should contain saveSetting JS function")
}
if !strings.Contains(html, `api("/settings"`) {
t.Fatal("HTML should call api('/settings')")
}
})
t.Run("settings_not_available_without_registry", func(t *testing.T) {
s2 := testSDK(sdk.SDKConfig{
Supervisor: supervisor.NewSDKAdapter(sup),
Config: sdk.NewConfig(&types.Config{}),
})
h2 := NewHandler(s2)
req := httptest.NewRequest(http.MethodGet, "/api/v1/settings", nil)
w := httptest.NewRecorder()
h2.handleSettings(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", w.Code)
}
})
}
func TestSettingsWithPluginRegistry(t *testing.T) {
cfgReg := internalConfig.NewConfigRegistry("")
cfgReg.Register("core.test.key", "value")
cfgReg.Register("plugin.testplug.apikey", "abc123")
sup := supervisor.New(&types.Config{
Daemon: types.DaemonConfig{
CheckInterval: time.Minute,
HeartbeatInterval: 30 * time.Second,
},
})
sup.Start()
defer sup.Shutdown()
s := testSDK(sdk.SDKConfig{
Supervisor: supervisor.NewSDKAdapter(sup),
Settings: sdk.NewSettings("webui", cfgReg),
Config: sdk.NewConfig(&types.Config{}),
})
h := NewHandler(s)
req := httptest.NewRequest(http.MethodGet, "/api/v1/settings", nil)
w := httptest.NewRecorder()
h.handleSettings(w, req)
var resp map[string]interface{}
json.NewDecoder(w.Body).Decode(&resp)
plugins, _ := resp["plugins"].([]interface{})
foundCore := false
for _, p := range plugins {
if p == "core" {
foundCore = true
break
}
}
if !foundCore {
t.Fatal("expected 'core' in plugins list")
}
}
// === 端到端测试Handler + IOManager + Agent + HTTP ===
type echoProvider struct{ name string }
func (p *echoProvider) Name() string { return p.name }
func (p *echoProvider) MaxContextTokens() int { return 8192 }
func (p *echoProvider) Chat(ctx context.Context, req *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
content := "echo: " + lastUserContent(req.Messages)
return &agentAPI.CompletionResponse{Content: content, FinishReason: "stop"}, nil
}
func (p *echoProvider) ChatStream(ctx context.Context, req *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
// 流契约chunk 发送完毕后必须 close(channel) 标识流结束(与
// LuaAdaptedProvider.ChatStream 的 defer close(ch) 一致);
// accumulateStream 以 channel 关闭为终止条件Done 只是 finish_reason 载体。
// 内容与 Chat() 保持一致,保证端到端断言在流式/非流式两条路径下等价。
ch := make(chan agentAPI.StreamChunk, 1)
content := "echo: " + lastUserContent(req.Messages)
ch <- agentAPI.StreamChunk{Content: content, Done: true, FinishReason: "stop"}
close(ch)
return ch, nil
}
func lastUserContent(msgs []agentAPI.Message) string {
for i := len(msgs) - 1; i >= 0; i-- {
if msgs[i].Role == "user" {
return msgs[i].Content
}
}
return ""
}
func init() {
// 避免测试时自动输出
}
func TestHandleCompletionsEndToEnd(t *testing.T) {
iom := agentIO.NewIOManager()
// 启动一个最小 Agent使用 echoProvider不调真实 LLM
memDB, err := memory.NewGraphDB(t.TempDir() + "/graph.db")
if err != nil {
t.Fatalf("NewGraphDB: %v", err)
}
defer memDB.Close()
pm := agentAPI.NewProviderManager()
pm.Register("echo", &echoProvider{name: "echo"})
agent := agentCore.New(agentCore.AgentConfig{
ID: "test",
SystemPrompt: "你是测试助手",
Provider: &echoProvider{name: "echo"},
ProviderManager: pm,
IO: iom,
Memory: memDB,
Indexer: nil,
ContextSavePath: "",
})
agent.Start()
defer agent.Stop()
sup := supervisor.New(&types.Config{
Daemon: types.DaemonConfig{
CheckInterval: time.Minute,
HeartbeatInterval: 30 * time.Second,
},
})
sup.Start()
defer sup.Shutdown()
s := testSDK(sdk.SDKConfig{
Supervisor: supervisor.NewSDKAdapter(sup),
IOManager: iom,
Config: sdk.NewConfig(&types.Config{}),
})
h := NewHandler(s)
t.Run("POST_chat_completions_returns_echo", func(t *testing.T) {
body := `{"model":"test","messages":[{"role":"user","content":"你好"}]}`
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handleOpenAICompletions(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode: %v", err)
}
choices, ok := resp["choices"].([]interface{})
if !ok || len(choices) == 0 {
t.Fatal("expected choices")
}
msg, ok := choices[0].(map[string]interface{})["message"].(map[string]interface{})
if !ok {
t.Fatal("expected message")
}
if msg["content"] != "echo: 你好" {
t.Fatalf("expected 'echo: 你好', got '%v'", msg["content"])
}
})
t.Run("POST_chat_completions_no_iom_returns_503", func(t *testing.T) {
s2 := testSDK(sdk.SDKConfig{
Supervisor: supervisor.NewSDKAdapter(sup),
})
h2 := NewHandler(s2)
body := `{"messages":[{"role":"user","content":"hi"}]}`
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h2.handleOpenAICompletions(w, req)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("expected 503, got %d", w.Code)
}
})
t.Run("POST_chat_completions_400_on_no_messages", func(t *testing.T) {
body := `{"model":"test"}`
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handleOpenAICompletions(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
})
t.Run("POST_chat_completions_400_on_non_user_last_msg", func(t *testing.T) {
body := `{"messages":[{"role":"assistant","content":"hi"}]}`
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
h.handleOpenAICompletions(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", w.Code)
}
})
}
// ===== client_msg_id 去重测试(防 GUI 断线重连消息重放)=====
func TestHandleChatClientMsgIDDedup(t *testing.T) {
iom := agentIO.NewIOManager()
memDB, err := memory.NewGraphDB(t.TempDir() + "/graph.db")
if err != nil {
t.Fatalf("NewGraphDB: %v", err)
}
defer memDB.Close()
pm := agentAPI.NewProviderManager()
pm.Register("echo", &echoProvider{name: "echo"})
agent := agentCore.New(agentCore.AgentConfig{
ID: "test",
SystemPrompt: "你是测试助手",
Provider: &echoProvider{name: "echo"},
ProviderManager: pm,
IO: iom,
Memory: memDB,
})
agent.Start()
defer agent.Stop()
sup := supervisor.New(&types.Config{
Daemon: types.DaemonConfig{
CheckInterval: time.Minute,
HeartbeatInterval: 30 * time.Second,
},
})
sup.Start()
defer sup.Shutdown()
s := testSDK(sdk.SDKConfig{
Supervisor: supervisor.NewSDKAdapter(sup),
IOManager: iom,
Config: sdk.NewConfig(&types.Config{}),
})
h := NewHandler(s)
t.Run("same_client_msg_id_replay_returns_cached_response", func(t *testing.T) {
body := `{"message":"你好","client_msg_id":"msg-abc-123"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/chat", strings.NewReader(body))
w := httptest.NewRecorder()
h.handleChat(w, req)
if w.Code != http.StatusOK {
t.Fatalf("first request: expected 200, got %d: %s", w.Code, w.Body.String())
}
var first map[string]interface{}
json.NewDecoder(w.Body).Decode(&first)
if first["response"] != "echo: 你好" {
t.Fatalf("expected echo response, got %v", first["response"])
}
// 同 ID 重放:应直接复用首次结果,不重复注入 agent
req2 := httptest.NewRequest(http.MethodPost, "/api/v1/chat", strings.NewReader(body))
w2 := httptest.NewRecorder()
h.handleChat(w2, req2)
if w2.Code != http.StatusOK {
t.Fatalf("replay: expected 200, got %d: %s", w2.Code, w2.Body.String())
}
var second map[string]interface{}
json.NewDecoder(w2.Body).Decode(&second)
if second["response"] != "echo: 你好" {
t.Fatalf("replay expected same response, got %v", second["response"])
}
if second["deduplicated"] != true {
t.Fatalf("replay expected deduplicated=true, got %v", second["deduplicated"])
}
})
t.Run("different_client_msg_id_processed_normally", func(t *testing.T) {
body := `{"message":"第二条","client_msg_id":"msg-def-456"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/chat", strings.NewReader(body))
w := httptest.NewRecorder()
h.handleChat(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
var resp map[string]interface{}
json.NewDecoder(w.Body).Decode(&resp)
if resp["deduplicated"] == true {
t.Fatal("new msg id should not be deduplicated")
}
})
t.Run("no_client_msg_id_backward_compatible", func(t *testing.T) {
body := `{"message":"旧客户端消息"}`
req := httptest.NewRequest(http.MethodPost, "/api/v1/chat", strings.NewReader(body))
w := httptest.NewRecorder()
h.handleChat(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
})
}
// ===== agent 核心层内容级去重测试 =====
func TestAgentDuplicateInputDedup(t *testing.T) {
iom := agentIO.NewIOManager()
memDB, err := memory.NewGraphDB(t.TempDir() + "/graph.db")
if err != nil {
t.Fatalf("NewGraphDB: %v", err)
}
defer memDB.Close()
pm := agentAPI.NewProviderManager()
pm.Register("echo", &echoProvider{name: "echo"})
agent := agentCore.New(agentCore.AgentConfig{
ID: "test",
SystemPrompt: "你是测试助手",
Provider: &echoProvider{name: "echo"},
ProviderManager: pm,
IO: iom,
Memory: memDB,
})
agent.Start()
defer agent.Stop()
// 直接验证 isDuplicateInput 行为
if agent.IsDuplicateInput("webui", "重复消息") {
t.Fatal("first input should not be duplicate")
}
if !agent.IsDuplicateInput("webui", "重复消息") {
t.Fatal("immediate same-content same-source should be duplicate")
}
if agent.IsDuplicateInput("webui", "不同消息") {
t.Fatal("different content should not be duplicate")
}
if agent.IsDuplicateInput("qq", "重复消息") {
t.Fatal("different source should not be duplicate")
}
}
// TestSettingsNoCrossPluginLeak 锁住设置接口的两类泄漏:
//
// 1. meta 不得把别家插件的 def 复制进来(曾经 28 插件 × ~186 def = 5208 条,
// 96% 重复),也不得出现 plugin.<a>.plugin.<b>.<key> 这种幻影键——
// 按幻影键写回会落到错误插件的配置表里。
// 2. chathistory 是内部数据(生产实测 5.2MB),不得出现在设置响应里,
// 也不得经设置接口写入。
func TestSettingsNoCrossPluginLeak(t *testing.T) {
cfgReg := internalConfig.NewConfigRegistry("")
cfgReg.RegisterDef(internalConfig.ConfigDef{Key: "core.agent.max_tool_turns", Default: "10"})
webuiCfg := cfgReg.PluginConfig("webui")
webuiCfg.RegisterDef(internalConfig.ConfigDef{Key: "addr", Default: ":8080"})
// 隔离聊天记录文件:不设的话会落到 os.TempDir()/webui_chat_history.json
// 与其它用例串味(迁移用例会把自己的数据写进去)。
webuiCfg.RegisterDef(internalConfig.ConfigDef{Key: "history_file", Default: ""})
if err := webuiCfg.Set("history_file", filepath.Join(t.TempDir(), "chat.json")); err != nil {
t.Fatalf("set history_file: %v", err)
}
webuiCfg.Set("addr", ":8080")
webuiCfg.Set("chathistory", `[{"role":"assistant","content":"secret blob"}]`)
qqCfg := cfgReg.PluginConfig("qq")
qqCfg.RegisterDef(internalConfig.ConfigDef{Key: "access_token", Default: ""})
qqCfg.Set("access_token", "qq-token")
sup := supervisor.New(&types.Config{Daemon: types.DaemonConfig{
CheckInterval: time.Minute, HeartbeatInterval: 30 * time.Second,
}})
sup.Start()
defer sup.Shutdown()
s := testSDK(sdk.SDKConfig{
Supervisor: supervisor.NewSDKAdapter(sup),
Settings: sdk.NewSettings("webui", cfgReg),
Config: sdk.NewConfig(&types.Config{}),
})
h := NewHandler(s)
req := httptest.NewRequest(http.MethodGet, "/api/v1/settings", nil)
w := httptest.NewRecorder()
h.handleSettings(w, req)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", w.Code)
}
var resp struct {
Settings map[string]interface{} `json:"settings"`
Meta map[string]map[string]interface{} `json:"meta"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal: %v", err)
}
// 1) meta 里每个键只能出现一次 "plugin." 前缀,且内容与键同源
for k, v := range resp.Meta {
if strings.Count(k, "plugin.") > 1 {
t.Fatalf("meta 出现幻影键:%q", k)
}
if inner, ok := v["key"].(string); ok && strings.HasPrefix(inner, "plugin.") {
t.Fatalf("meta[%q].key 带命名空间前缀(会造成双重前缀):%q", k, inner)
}
}
// 2) webui 不能看到 qq 的 def反之亦然
if _, ok := resp.Meta["plugin.webui.addr"]; !ok {
t.Fatalf("meta 缺少 plugin.webui.addr%v", resp.Meta)
}
if _, ok := resp.Meta["plugin.webui.access_token"]; ok {
t.Fatal("meta 里出现了别家插件的 defplugin.webui.access_token")
}
if _, ok := resp.Meta["plugin.qq.access_token"]; !ok {
t.Fatal("meta 缺少 plugin.qq.access_token")
}
if _, ok := resp.Meta["plugin.qq.addr"]; ok {
t.Fatal("meta 里出现了别家插件的 defplugin.qq.addr")
}
// 3) 内部数据不进设置面
if _, ok := resp.Settings["plugin.webui.chathistory"]; ok {
t.Fatal("settings 泄露了 chathistory 内部数据")
}
if _, ok := resp.Meta["plugin.webui.chathistory"]; ok {
t.Fatal("meta 泄露了 chathistory")
}
if _, ok := resp.Settings["plugin.qq.access_token"]; !ok {
t.Fatal("普通插件配置项应照常返回")
}
// 4) 内部数据也不可经设置接口写入
body := `{"key":"plugin.webui.chathistory","value":"tampered"}`
preq := httptest.NewRequest(http.MethodPut, "/api/v1/settings", strings.NewReader(body))
preq.Header.Set("Content-Type", "application/json")
pw := httptest.NewRecorder()
h.handleSettings(pw, preq)
if pw.Code != http.StatusBadRequest {
t.Fatalf("写内部键应被拒400实际 %d", pw.Code)
}
// 迁移已把这条记录搬去独立文件并从配置表移除;若仍在,则必须是原值(未被改写)
if got, _ := webuiCfg.Get("chathistory"); got != nil && got != `[{"role":"assistant","content":"secret blob"}]` {
t.Fatalf("内部数据被改写:%v", got)
}
// 记录本身必须没有丢:迁移后的文件里应能找到它
msgs := h.history.Load()
if len(msgs) != 1 || msgs[0].Content != "secret blob" {
t.Fatalf("迁移后记录不应丢失,实际 %+v文件 %s", msgs, h.history.Path())
}
}
// TestListenOverrideAndBindFailure 钉住两个曾经静默的缺陷:
//
// 1. CLI --webui / webui.listen_addr 的覆盖必须真的生效(优先级高于插件 settings["addr"])。
// 修复前内核只在插件设置键为空时才写,而 REGISTERDEF 建表时写的是默认 :8080
// 覆盖因此永远是死配置。
// 2. 端口被占时 Start 必须返回错误。修复前监听在后台 goroutine 里做,
// Start 永远返回 nilWebUI 静默死亡而插件仍被当成加载成功。
func TestListenOverrideAndBindFailure(t *testing.T) {
// 取一个确定空闲的地址
probe, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("probe listen: %v", err)
}
addr := probe.Addr().String()
probe.Close()
cfgReg := internalConfig.NewConfigRegistry("")
seedWebUIConfig(cfgReg)
// 插件设置里故意放一个别的地址,用来证明覆盖的优先级
cfgReg.PluginConfig("webui").Set("addr", "127.0.0.1:1")
s := testSDK(sdk.SDKConfig{
Settings: sdk.NewSettings("webui", cfgReg),
Config: sdk.NewConfig(&types.Config{}),
EventBus: events.NewBus(),
IOManager: agentIO.NewIOManager(),
})
SetListenOverride(addr)
defer SetListenOverride("")
p1 := New("webui")
if err := p1.Start(s); err != nil {
t.Fatalf("Start with override: %v", err)
}
defer p1.Stop()
// 覆盖值必须真的在监听
cli := &http.Client{Timeout: 2 * time.Second}
resp, err := cli.Get("http://" + addr + "/login")
if err != nil {
t.Fatalf("覆盖地址未监听(%s%v", addr, err)
}
resp.Body.Close()
// 同一地址再来一个插件实例 → 必须同步报错
p2 := New("webui")
err = p2.Start(s)
if err == nil {
p2.Stop()
t.Fatal("端口被占用时 Start 应返回错误,而不是静默成功")
}
if !strings.Contains(err.Error(), "监听") {
t.Fatalf("错误信息应说明监听失败,实际:%v", err)
}
}
func TestResolveListenAddrPrecedence(t *testing.T) {
SetListenOverride("")
defer SetListenOverride("")
if got := resolveListenAddr(""); got != ":8080" {
t.Fatalf("默认应为 :8080得到 %q", got)
}
if got := resolveListenAddr(":9001"); got != ":9001" {
t.Fatalf("插件设置应生效,得到 %q", got)
}
SetListenOverride("127.0.0.1:9002")
if got := resolveListenAddr(":9001"); got != "127.0.0.1:9002" {
t.Fatalf("覆盖值应优先,得到 %q", got)
}
}
// TestResolveHistoryFile 钉住聊天记录路径的解析规则:
// 插件设置优先、相对路径按 data 目录解析、绝对路径原样、留空走默认。
func TestResolveHistoryFile(t *testing.T) {
cases := []struct{ setting, dataDir, want string }{
{"", "/data", "/data/webui_chat_history.json"},
{"chat.json", "/data", "/data/chat.json"},
{"sub/chat.json", "/data", "/data/sub/chat.json"},
{"/mnt/ssd/chat.json", "/data", "/mnt/ssd/chat.json"},
{" ", "/data", "/data/webui_chat_history.json"},
}
for _, c := range cases {
if got := resolveHistoryFile(c.setting, c.dataDir); got != c.want {
t.Errorf("resolveHistoryFile(%q, %q) = %q, want %q", c.setting, c.dataDir, got, c.want)
}
}
// data 目录未知时不得落到进程 CWD测试/嵌入场景会污染工作目录)
if got := resolveHistoryFile("", ""); filepath.Dir(got) != strings.TrimRight(os.TempDir(), "/") {
t.Errorf("data 目录未知时应落到临时目录,实际 %q", got)
}
}
// TestHistoryStoreMigratesFromConfig 钉住从「配置项存整段记录」到「独立文件」的迁移:
// 记录必须完好搬到文件、老配置项必须从配置表消失(它正是 config.db 膨胀与设置接口
// 大响应的来源),且第二次加载不再重复迁移。
func TestHistoryStoreMigratesFromConfig(t *testing.T) {
dir := t.TempDir()
file := filepath.Join(dir, "chat.json")
cfgReg := internalConfig.NewConfigRegistry("")
webuiCfg := cfgReg.PluginConfig("webui")
webuiCfg.RegisterDef(internalConfig.ConfigDef{Key: "chathistory", Default: ""})
legacy := []ChatMsg{
{Role: "user", Content: "老记录 1", Time: "2026-01-01T00:00:00Z"},
{Role: "assistant", Content: "老记录 2", Time: "2026-01-01T00:00:01Z"},
}
b, _ := json.Marshal(legacy)
webuiCfg.Set("chathistory", string(b))
settings := sdk.NewSettings("webui", cfgReg)
hs := newHistoryStore(file)
got := hs.LoadWithMigration(settings)
if len(got) != 2 || got[0].Content != "老记录 1" || got[1].Content != "老记录 2" {
t.Fatalf("迁移后应拿到 2 条老记录,实际 %+v", got)
}
// 文件已落盘
onDisk := newHistoryStore(file).Load()
if len(onDisk) != 2 {
t.Fatalf("记录应写入 %s实际 %+v", file, onDisk)
}
// 老配置项必须消失(否则 config.db 里那 5MB 还在)
if v, _ := webuiCfg.Get("chathistory"); v != nil {
t.Fatalf("迁移后老配置项应被删除,实际仍为 %v", v)
}
// 二次加载:直接读文件,不重复迁移
hs2 := newHistoryStore(file)
if again := hs2.LoadWithMigration(settings); len(again) != 2 {
t.Fatalf("二次加载应仍为 2 条,实际 %+v", again)
}
// 文件损坏时按空历史处理,不得 panic
if err := os.WriteFile(file, []byte("{不是 JSON"), 0644); err != nil {
t.Fatal(err)
}
if broken := newHistoryStore(file).Load(); len(broken) != 0 {
t.Fatalf("损坏文件应按空历史处理,实际 %+v", broken)
}
}
// TestHistoryStoreSaveIsAtomicAndRoundTrips 钉住原子写:不留 .tmp、内容可回读。
func TestHistoryStoreSaveIsAtomicAndRoundTrips(t *testing.T) {
dir := t.TempDir()
file := filepath.Join(dir, "sub", "chat.json") // 目录不存在Save 需自建
hs := newHistoryStore(file)
msgs := []ChatMsg{{Role: "user", Content: "你好", Time: "2026-01-01T00:00:00Z"}}
if err := hs.Save(msgs); err != nil {
t.Fatalf("Save: %v", err)
}
if _, err := os.Stat(file + ".tmp"); !os.IsNotExist(err) {
t.Fatalf("不应残留 %s.tmp", file)
}
got := hs.Load()
if len(got) != 1 || got[0].Content != "你好" {
t.Fatalf("回读不一致:%+v", got)
}
}
// TestChatPersistenceIsThrottled 钉住聊天记录写盘节流:
// 连续变更不得每条都整段重写文件,但 Close 前必须把最后一次落盘(否则丢对话)。
func TestChatPersistenceIsThrottled(t *testing.T) {
dir := t.TempDir()
file := filepath.Join(dir, "chat.json")
cfgReg := internalConfig.NewConfigRegistry("")
webuiCfg := cfgReg.PluginConfig("webui")
webuiCfg.RegisterDef(internalConfig.ConfigDef{Key: "history_file", Default: ""})
webuiCfg.Set("history_file", file)
s := testSDK(sdk.SDKConfig{
Settings: sdk.NewSettings("webui", cfgReg),
Config: sdk.NewConfig(&types.Config{}),
EventBus: events.NewBus(),
})
h := NewHandler(s)
if h.history.Path() != file {
t.Fatalf("history 路径应为 %s实际 %s", file, h.history.Path())
}
// 模拟一轮对话里的连续变更(用户消息 + 多个工具事件 + 收尾)
base := time.Now()
for i := 0; i < 20; i++ {
h.chatMu.Lock()
h.chatHistory = append(h.chatHistory, ChatMsg{
Role: "assistant", Content: "消息", Time: base.Add(time.Duration(i) * time.Second).Format(time.RFC3339),
})
h.persistChatLocked()
h.chatMu.Unlock()
}
// 节流窗口内不应落盘
if _, err := os.Stat(file); err == nil {
t.Fatal("节流窗口3s内不应已经写盘")
}
// Close 必须把最后一次变更落下去
h.Close()
msgs := newHistoryStore(file).Load()
if len(msgs) != 20 {
t.Fatalf("Close 后应有 20 条记录,实际 %d", len(msgs))
}
// Close 幂等
h.Close()
}
// TestDashboardAssetsSplit 钉住前端的拆分方式:
// 外壳 dashboard.html 只留两个占位符,样式与脚本分别在 dashboard.css / dashboard.js
// 启动时组装回**与拆分前逐字节一致**的页面(拆分当日已用 git 里的原文件比对通过)。
func TestDashboardAssetsSplit(t *testing.T) {
shell, err := dashboardFS.ReadFile("dashboard.html")
if err != nil {
t.Fatalf("读 dashboard.html: %v", err)
}
for _, ph := range []string{"{{DASHBOARD_CSS}}", "{{DASHBOARD_JS}}"} {
if !strings.Contains(string(shell), ph) {
t.Fatalf("外壳里应保留占位符 %s", ph)
}
}
// 样式/脚本不能又塞回外壳(否则拆分名存实亡)
if strings.Contains(string(shell), "--sakura-300") {
t.Fatal("dashboard.html 里仍内联着样式,拆分未生效")
}
if strings.Contains(string(shell), "function saveSetting") {
t.Fatal("dashboard.html 里仍内联着脚本,拆分未生效")
}
css, err := dashboardFS.ReadFile("dashboard.css")
if err != nil || len(css) == 0 {
t.Fatalf("读 dashboard.css: %v (%d 字节)", err, len(css))
}
js, err := dashboardFS.ReadFile("dashboard.js")
if err != nil || len(js) == 0 {
t.Fatalf("读 dashboard.js: %v (%d 字节)", err, len(js))
}
// 组装结果:占位符必须都被替换掉,且样式/脚本内容都在里面
if strings.Contains(dashboardHTML, "{{DASHBOARD_") {
t.Fatal("组装后的页面仍残留占位符")
}
if !strings.Contains(dashboardHTML, "--sakura-300") {
t.Fatal("组装后的页面缺样式")
}
if !strings.Contains(dashboardHTML, "function saveSetting") {
t.Fatal("组装后的页面缺脚本")
}
}
// TestChatHistoryDefaultIsPaged 钉住 /chat/history 的默认页大小。
//
// 原先缺省 limit=0 表示"不限制",于是任何不带 limit 的调用每次都拿到完整聊天记录
// (生产实测 ~5MB本地 126 条 635KB——这正是"每次都在发完整聊天记录"的来源。
// 现在默认只回一页;想整取必须显式 limit=0。
func TestChatHistoryDefaultIsPaged(t *testing.T) {
h, _ := newTestHandler(t)
// 清空可能从临时目录共享文件加载进来的历史,让用例只依赖自己播的数据
h.chatMu.Lock()
h.chatHistory = nil
for i := 0; i < maxChatHistory; i++ {
h.chatHistory = append(h.chatHistory, ChatMsg{Role: "user", Content: "消息", Time: "t"})
}
h.chatMu.Unlock()
get := func(q string) map[string]interface{} {
req := httptest.NewRequest(http.MethodGet, "/api/v1/chat/history"+q, nil)
w := httptest.NewRecorder()
h.handleChatHistory(w, req)
if w.Code != http.StatusOK {
t.Fatalf("GET %q: %d", q, w.Code)
}
var out map[string]interface{}
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
return out
}
// 不带 limit → 只回一页,且明确告知还有更早的
def := get("")
if n := len(def["messages"].([]interface{})); n != defaultChatHistoryLimit {
t.Fatalf("默认应回 %d 条,实际 %d", defaultChatHistoryLimit, n)
}
if def["has_more"] != true {
t.Fatal("还有更早内容时 has_more 应为 true")
}
// 显式 limit=0 → 整取(逃生口):返回条数应等于 total
all := get("?limit=0")
total := int(all["total"].(float64))
if n := len(all["messages"].([]interface{})); n != total {
t.Fatalf("limit=0 应整取 total=%d 条,实际 %d", total, n)
}
// 显式分页仍然照旧
page := get("?limit=5")
if n := len(page["messages"].([]interface{})); n != 5 {
t.Fatalf("limit=5 应回 5 条,实际 %d", n)
}
}