refactor(webui): 7 个黑箱判据迁到 test/ + 导出前端资源入口

## 迁移了什么

    chat_ack_timeout_test.go       → test/
    chat_attachment_img_test.go    → test/
    cmd_history_render_test.go     → test/
    config_namespace_test.go       → test/
    dashboard_tabs_deps_test.go    → test/
    plugin_status_render_test.go   → test/
    starmap_perf_test.go           → test/

包名 `webui` → `test`。`webui/` 目录少 7 个测试文件。

## 为什么是这 7 个(分类依据)

先按「对未导出符号的依赖数」把 29 个测试文件测了一遍,这 7 个是
**0 依赖**(既不用 handleXxx,也不用 testSDK/newTestHandler 等测试辅助)。
其余 22 个按强度递增留在包内,后续逐步处理。

## 新增两个导出入口

### webui.FrontendAsset(name)

返回打包进二进制的前端资源原文(dashboard.js / dashboard.css /
mascot.webp / logo.svg …)。

判据要检查「实际会被发出去的那份文本」:dashboard.js 在严格模式下
会不会抛 ReferenceError(用 node 真跑)、mascot.webp 的魔数是否合法、
HTML 属性里有没有残留转义引号。

这些无法通过 HTTP 端点可靠断言(要登录态、且拿到的是拼接后整页,
定位不到单个 asset)。而留在包内测就得读 `dashboardFS` 这个**未导出变量**
—— 那正是"测试耦合实现"。导出后,判据变成「输入 asset 名 → 输出字节」,
与资源存在哪个变量、怎么 embed 无关。

### webui.DashboardPage()

返回组装完成的整页 HTML(外壳 + 内联 CSS/JS)—— 浏览器实际收到的那份。
与 FrontendAsset 的分工:那个取单个源资产,这个取成品。

## 顺带修掉迁移暴露的两个隐患

① 三个判据原本 `os.ReadFile("dashboard.js")` 读**磁盘相对路径**。
   在包内恰好能跑(工作目录就是包目录),但那读的是**工作区文件**,
   不是**二进制里实际嵌入的那份** —— 两者在生产上可能不同
   (比如打包后 dashboard.js 被替换)。统一改走 FrontendAsset。

② starmap_perf_test 的相对路径 `../../../shared/starmap/layout.js`
   与 `static/starmap-layout-worker.js` 在移入子目录后失效,已修正为
   `../../../../shared/...` 与 `../static/...`。

## 判据仍有效(变异验证)

把 dashboard.js 里 ack 超时 `}, 600000);` 改成 `}, 30000);`:

    --- FAIL: TestChatAckTimeoutIsGenerous
        ★ 受理超时只有 30000 ms —— 太小。
          超时太短会让前端 abort,而 agent 仍在正常干活

恢复后全绿。迁移不是搬完就废。

## 验证

    go build ./...            PASS
    go vet ./...              PASS
    gofmt -l                  干净
    go test ./... -count=1    63 包全过,0 FAIL
                             (webui/test 是新包,故 62 → 63)
    干净检出(worktree)       build + webui/... 全绿

## 后续

    webui 剩余 22 个文件(依赖 handleXxx / testSDK 等),按强度递增处理
    agent/core 739 处白盒引用,先分类再动
    CI 补 coverage + staticcheck
This commit is contained in:
JianFeeeee
2026-11-04 18:17:31 +08:00
parent 7cef7df6f2
commit 87f874e06f
10 changed files with 140 additions and 31 deletions

View File

@ -0,0 +1,98 @@
package test
// 聊天受理超时的判据(2026-10-17 内测故障)。
//
// ## 故障现象
//
// 用户发一句话,界面回「已取消(用户停止)」。而实际日志显示
// agent **一直在正常干活**,并且跑完了:
//
// 20:58:36 POST /chat 开始
// 20:58:52 ★ 前端 15s ackTimer abort → 服务端 status=504 (15.89s)
// 20:59:29 ★ agent 正常完成,52552ms,22 个工具,返回 response
//
// 也就是说:回复被生成了,但 HTTP 连接已被前端自己掐断 ⇒ 送不回去,
// 用户看到的是"已取消(用户停止)"——一个**用户从没做过的操作**。
//
// ## 根因
//
// 前端 `sendChat()` 里把"受理确认"的等待写死成 15 秒,而 agent 的
// 正常任务动辄 45-55 秒(工具越多越久)。这个 POST 本来就是**触发式**的,
// 真正渲染靠 SSE,没必要在这里抢时间。
//
// ## 判据
//
// 1. 那个超时必须远大于实测的正常任务耗时(不能是 15s 这种量级)
// 2. 超时后的提示不能说成"失败/超时错误",否则用户会重发造成双任务
//
// ★ 为什么用源码扫描而不是行为测试:这是前端 abort 时序,
// 没有可注入的时钟。扫描至少能防住"有人把 15s 改回来"。
import (
"regexp"
"strconv"
"strings"
"testing"
)
// dashboardJS 见 helpers_test.go 的同名函数(经 webui.FrontendAsset 读嵌入资源)。
// ★★★ 判据:受理超时必须 ≥ 5 分钟。
//
// 实测正常任务 45-55 秒;留 5 分钟以上余量,
// 保证"任务慢"不会被误判成"连接坏了"。
func TestChatAckTimeoutIsGenerous(t *testing.T) {
src := dashboardJS(t)
// 找 ackTimer 的 setTimeout 时长
re := regexp.MustCompile(`var ackTimer = setTimeout\(\(\) => \{\s*ctrl\.abort\(\);\s*\}, (\d+)\)`)
m := re.FindStringSubmatch(src)
if m == nil {
t.Fatal("找不到 ackTimer 的超时值 —— 判据需要更新(代码结构变了?)")
}
ms, err := strconv.Atoi(m[1])
if err != nil {
t.Fatalf("超时值不是数字: %q", m[1])
}
const minAck = 300000 // 5 分钟
if ms < minAck {
t.Fatalf("★ 受理超时只有 %d ms —— 太小。\n"+
" 实测 agent 正常任务就要 45-55 秒(22 个工具那次 52552ms);\n"+
" 超时太短会让前端 abort,而 agent 仍在正常干活,\n"+
" 结果是用户看到「已取消(用户停止)」——他从没点过停止。", ms)
}
}
// ★★★ 判据:超时提示不得把"还在处理"说成"失败"。
func TestChatAckTimeoutMessageDoesNotBlameFailure(t *testing.T) {
src := dashboardJS(t)
// 定位 ack 失败分支附近的 toast 文案
i := strings.Index(src, "catch (ackErr)")
if i < 0 {
t.Fatal("找不到 ack 失败分支")
}
seg := src[i:]
if end := strings.Index(seg, "} finally {"); end > 0 {
seg = seg[:end]
}
// 不能出现"失败/错误/Error/failed"这类归因
for _, bad := range []string{"请求失败", "Request failed", "请求超时", "Request timeout"} {
if strings.Contains(seg, bad) {
t.Fatalf("★ ack 超时提示用了 %q —— 会让用户以为出错了而重发,"+
" 造成同一句话产生两条任务(实测 agent 其实还在正常跑)", bad)
}
}
// 必须告诉用户"已发出、正在处理、不要重发"
if !strings.Contains(seg, "正在处理") && !strings.Contains(seg, "is working") {
t.Fatal("★ ack 超时提示没说清'AI 正在处理' —— 用户会以为消息丢了")
}
if !strings.Contains(seg, "不要重复发送") && !strings.Contains(seg, "do not resend") {
t.Fatal("★ 没提醒不要重发 —— 重发会造成重复任务")
}
// 不能标成 error(第二个参数 true)
if strings.Contains(seg, ",\n\t\t\t\ttrue,\n\t\t\t);") && !strings.Contains(seg, "false,") {
t.Log("提示: 建议用非 error 样式展示(这不是错误)")
}
}

View File

@ -0,0 +1,93 @@
package test
// 聊天图片附件的 HTML 必须**能被浏览器正确切分**(2026-10-30)。
//
// ## 缺陷现场
//
// 生产截图:图片明明加载成功了(img 元素在、src 正确),界面上却多出一行
// 蓝色文字「图片加载失败"/>」。
//
// 根因是内联事件属性里用了反斜杠转义:
//
// onerror="this.parentElement.innerHTML='<span class=\"att-err\">图片加载失败</span>'"
//
// **HTML 属性值里没有反斜杠转义** —— 那个 \" 就是引号本身,属性在
// `<span class=` 之后就闭合了。浏览器切出来的是:
//
// onerror = "this.parentElement.innerHTML='<span class="
//
// 剩下的 `att-err">图片加载失败</span>'"/>` 变成**文本节点**直接显示。
// 而那个 onerror 因为语法错误永远不会执行 —— 真失败时反而什么都不显示。
//
// ## 为什么这条判据值得写
//
// 这种缺陷**不会让任何构建或测试失败**:JSON 合法、JS 语法合法、
// 字符串拼接合法。它只在浏览器里错,而且症状("加载成功却显示加载失败")
// 指向的方向与真因(引号归属)完全不同。
//
// 判据做法:按正则把 HTML 里所有 `name="value"` 属性切出来,
// 断言每个值内部**不含裸引号**,并断言页面里没有残留的 `"/>` 文本片段。
import (
"regexp"
"strings"
"testing"
)
// attrRe 匹配 HTML 属性。用它模拟浏览器"走到下一个引号就闭合"的行为。
var attrRe = regexp.MustCompile(`([a-zA-Z_:][-a-zA-Z0-9_:.]*)="([^"]*)"`)
// TestChatAttachmentImgHasNoBrokenInlineHandler 钉住附件图片的 HTML 形态。
func TestChatAttachmentImgHasNoBrokenInlineHandler(t *testing.T) {
js := stripJSComments(dashboardJS(t))
if !strings.Contains(js, "chat-attachment-img") {
t.Fatal("dashboardJS 里找不到 chat-attachment-img —— 附件图片渲染被改名或删了?")
}
// ① 内联 onerror 不能再出现:它是本缺陷的载体。
//
// 真正的失败处理已改为**捕获阶段的事件委托**(error 事件不冒泡,
// 只能在捕获阶段拿到)。所以这里断言"没有内联 onerror"是合理的收紧,
// 而不是"不许处理错误"。
if strings.Contains(js, `onerror="this.parentElement`) {
t.Error("★ 附件图片仍在用内联 onerror —— " +
"HTML 属性不支持反斜杠转义,属性会被提前闭合、剩余部分变成可见文本。" +
"请用捕获阶段的 error 事件委托(见 renderChat 里的 addEventListener(\"error\", ..., true))")
}
// ② 失败占位必须走 DOM API,不能拼 innerHTML 字符串。
//
// innerHTML 拼接是这条缺陷的来源(要在字符串里同时处理 HTML 引号、
// JS 引号、CSS 选择器)。用 createElement + textContent 只需处理数据。
if !strings.Contains(js, `span.className = "att-err"`) {
t.Error("找不到 .att-err 的 DOM 构造 —— 失败占位应改用 createElement/textContent")
}
// ③ 必须有捕获阶段的 error 委托(缺了它,图片失败时用户看到破图无提示)。
//
// 注意:error 事件不冒泡,`addEventListener("error", fn)` 不带 true
// 是**收不到** img 加载失败的 —— 这条注释是为了让改的人别再踩。
if !regexp.MustCompile(`addEventListener\(\s*"error"`).MatchString(js) {
t.Error("缺少 error 事件监听 —— 图片加载失败时不会显示占位")
}
}
// TestDashboardNoEscapedQuoteInHTMLAttributes 是**通用**的同类缺陷扫描。
//
// 它不针对某一行,而是扫整个 dashboard.js:凡是形如 `="...\"..."` 的
// 拼接,都会在浏览器里被提前闭合。
//
// 这条判据的价值在于:下次别人再写内联事件(onclick/onerror/onload…)
// 并试图用 \" 转义时,这里会红,而不是等到生产上出现一行诡异的可见文本。
func TestDashboardNoEscapedQuoteInHTMLAttributes(t *testing.T) {
js := stripJSComments(dashboardJS(t))
// 找 `="` 之后到下一个 `"` 之间出现反斜杠转义引号的片段。
// 注意要排除 JS 字符串里的普通转义(如 "a\"b" 作为纯数据),
// 所以只匹配**紧跟在 HTML 属性名后面**的形态:空白 + 名字 + = "
bad := regexp.MustCompile(`[a-zA-Z-]+="[^"]*\\"`)
if m := bad.FindString(js); m != "" {
t.Errorf("★ 发现 HTML 属性内用反斜杠转义引号(浏览器会提前闭合属性):\n %s\n"+
"HTML 属性值里没有转义序列 —— 需要嵌套引号时改用单引号包属性值,"+
"或改用 DOM API / 事件委托。", m)
}
}

View File

@ -0,0 +1,130 @@
package test
import (
"strings"
"testing"
)
// renderCmdHistory 的**回归判据**(2026-10-19 生产缺陷)。
//
// ## 原始 bug
//
// 「命令历史」面板的数据链路是:
//
// 内核 TerminalRegistry.CmdHistory() → /api/v1/cmd/history
// → loadCmdHistory() 写入 state.cmdHistory → ??? (到此为止)
//
// 旧版 renderCmdHistory 渲染的是 state.terminals 里 running 的终端,
// **state.cmdHistory 从未被读过**。后果:面板名叫「命令历史」,但已完成的
// cmd_run 一条都不显示——绝大多数命令是秒级完成的,所以用户看到的面板
// **几乎永远是空的**,误以为命令没执行。
//
// ## 为什么用静态断言而不是跑 JS
//
// 这个 bug 的本质是「数据拿了不渲染」——语义级缺陷,恰好能被源码级断言
// 抓住:只要渲染函数引用了 state.cmdHistory,且回归路径(只看 terminals)
// 被排除,链路就闭合了。这与 dashboard_js_scope_test.go 的作用域判据同款思路:
// Go 侧测试不执行 JS,但能守住 JS 源码的关键结构。
// cmdHistoryRendererSource 取 renderCmdHistory 的函数体。
func cmdHistoryRendererSource(t *testing.T) string {
t.Helper()
src := dashboardJSSource(t)
start := strings.Index(src, "function renderCmdHistory()")
if start < 0 {
t.Fatal("dashboard.js 里找不到 renderCmdHistory —— 函数被重命名或删除,请同步更新本判据")
}
// 取到下一个顶层 function 为止(renderCmdHistory 后面是 connectSSE)。
end := strings.Index(src[start+1:], "\nfunction ")
if end < 0 {
end = len(src) - start - 1
}
return src[start : start+end]
}
// TestCmdHistoryRendersCmdHistoryData 锁死数据源:必须读 state.cmdHistory。
//
// 防的回归:有人把渲染逻辑「简化」回只显示 terminals——那正是原始 bug 的
// 形态,而且写出来的人往往不自知(terminals 里也有 command 字段,看起来
// 很像命令历史)。
func TestCmdHistoryRendersCmdHistoryData(t *testing.T) {
body := cmdHistoryRendererSource(t)
if !strings.Contains(body, "state.cmdHistory") {
t.Error("renderCmdHistory 没有读取 state.cmdHistory —— " +
"「命令历史」面板将不显示任何已完成的 cmd_run(原始 bug 回归:数据拉回来却从不渲染)")
}
}
// TestCmdHistoryShowsSuccessAndFailure 锁死结果语义:成功/失败必须可区分。
//
// 命令历史的核心价值就是让人看到「哪条失败了」。没有成功/失败标识的
// 渲染等于只给了一半信息,失败命令与成功命令混在一起无法排查。
func TestCmdHistoryShowsSuccessAndFailure(t *testing.T) {
body := cmdHistoryRendererSource(t)
// 成功与失败要有不同的视觉标识(badge 颜色或等价物),两者都要出现。
hasOK := strings.Contains(body, "badge-green")
hasFail := strings.Contains(body, "badge-red")
if !hasOK || !hasFail {
t.Errorf("renderCmdHistory 缺少成功/失败的视觉区分(badge-green=%v badge-red=%v)—— "+
"命令历史的价值就在区分成败,混在一起等于没渲染", hasOK, hasFail)
}
// 退出码必须参与判定(exit_code 或等价字段),否则无法区分「status 缺失」的条目。
if !strings.Contains(body, "exit_code") && !strings.Contains(body, "status") {
t.Error("renderCmdHistory 没有使用 exit_code/status —— 成败判定缺少依据")
}
}
// TestCmdHistoryEscapesOutput 锁死 XSS 面:命令输出必须经 escHtml。
//
// 命令输出是**外部内容**:cmd_run 跑的是任意命令,输出里可以有
// <script>、属性注入等。渲染时不过 escHtml 就是把执行结果直接
// 插进 DOM —— 一次 cat 恶意文件就能在 WebUI 里执行任意 JS。
func TestCmdHistoryEscapesOutput(t *testing.T) {
body := cmdHistoryRendererSource(t)
// 输出段(stdout/stderr 拼接处)必须走 escHtml。
if !strings.Contains(body, "escHtml(out") && !strings.Contains(body, "escHtml(c.stdout") {
t.Error("renderCmdHistory 渲染命令输出时没有调用 escHtml —— " +
"输出是外部内容,直接 innerHTML 插入是 XSS 注入点")
}
// 命令文本同样来自外部(参数里可带 HTML),也要转义。
if !strings.Contains(body, "escHtml(c.command") {
t.Error("renderCmdHistory 渲染命令文本时没有调用 escHtml —— 命令参数同样可注入 HTML")
}
}
// TestCmdHistoryKeepsRunningTerminals 锁死旧行为里**有效**的部分:
// 运行中的终端仍要显示。
//
// 原始 bug 的旧实现虽然数据源错了,但「显示运行中的终端」这件事本身
// 是对的(长命令确实需要能在这里看到)。重写时必须保留,不能顺手删掉。
func TestCmdHistoryKeepsRunningTerminals(t *testing.T) {
body := cmdHistoryRendererSource(t)
if !strings.Contains(body, "running") {
t.Error("renderCmdHistory 不再显示运行中的终端 —— " +
"长命令(下载/渲染/构建)需要在命令历史面板可见,这是旧实现里正确的一半")
}
}
// TestCmdHistoryLoadWritesState 锁死链路上游:loadCmdHistory 必须写 state。
//
// 渲染函数对了、但加载函数不写 state,链路同样断。这条判据保证
// 「/api/v1/cmd/history → state.cmdHistory」这一段不被改坏。
func TestCmdHistoryLoadWritesState(t *testing.T) {
src := dashboardJSSource(t)
start := strings.Index(src, "async function loadCmdHistory()")
if start < 0 {
t.Fatal("dashboard.js 里找不到 loadCmdHistory")
}
end := strings.Index(src[start+1:], "\nfunction ")
if end < 0 {
end = len(src) - start - 1
}
body := src[start : start+end]
if !strings.Contains(body, "state.cmdHistory") {
t.Error("loadCmdHistory 没有把响应写入 state.cmdHistory —— " +
"渲染端永远拿到空数组,命令历史面板将恒为空")
}
if !strings.Contains(body, "/cmd/history") {
t.Error("loadCmdHistory 没有请求 /cmd/history —— 数据源被换掉时必须同步更新本判据")
}
}

View File

@ -0,0 +1,123 @@
package test
import (
"path/filepath"
"strings"
"testing"
internalConfig "github.com/JianFeeeee/HologramAgent/internal/config"
)
// 门禁:**全局 config 表里不得出现 plugin.<name>.* 形态的键**。
//
// ## 要判的是什么(2026-10-05 生产清理)
//
// 生产 config.db 的全局 `config` 表里躺着:
//
// plugin.webui.username = admin
// plugin.webui.password = admin123 ← 明文口令
// plugin.webui.api_key = admin123 ← 明文密钥
//
// 而**生效凭证根本不在这张表**:`getWebUIConfig` 走 `h.settings.Get`
// → `PluginConfig("webui")` → `config_webui` 表,那里是 jianf/jinrui233719。
//
// ⇒ 那三行是**旧版 SDK 的残留**(那时 Settings() 还写全局表)。
// 现在没有任何代码路径会再写它们(已用判据钉住,见下),
// 但留在库里就是明文泄露:备份、日志、误 dump、任何能读 config.db 的人。
//
// ★ 读不到 ≠ 不该存在。密钥类数据留在库里本身就是风险。
//
// ## 判据钉的是「命名空间纪律」而非逐键列举
//
// `plugin.<name>.<key>` 形态的键只允许出现在 `config_<name>` 表
// (那是插件配置的合法位置)。全局表里出现即命名空间错误。
// 比「这些键现在没被读」强:将来任何插件误写全局表都会被抓到。
// newCfgRegistry 建一个带默认播种的临时 registry。
func newCfgRegistry(t *testing.T) *internalConfig.ConfigRegistry {
t.Helper()
dir := t.TempDir()
reg := internalConfig.NewConfigRegistry(filepath.Join(dir, "config.db"))
reg.SeedDefaults(dir)
t.Cleanup(func() { reg.Close() })
return reg
}
// TestPluginSettingsGoesToPluginTable 钉住「插件的 Set 落 config_<name>,不是全局表」。
//
// ★ 这条是正向断言,比「不许出现 plugin.* 键」更根本:
//
// 只要 Settings().Set 还写 config_<name>,全局表就不会再长出 plugin.* 键。
// 生产那三行残留正是因为历史上它曾经写全局表。
func TestPluginSettingsGoesToPluginTable(t *testing.T) {
reg := newCfgRegistry(t)
// ★ 必须先 RegisterDef:插件配置表(config_<name>)由 RegisterDef 创建
// (见 registry.go 的 ensurePluginTable),而插件 Start 时也是这么做的。
// 直接 Set 会得到 "no such table: config_webui"——那不是缺陷,
// 是「表尚未随定义建立」的正常时序。
ps := reg.PluginConfig("webui")
ps.RegisterDef(internalConfig.ConfigDef{
Key: "password", Default: "", Type: "password",
})
const secret = "s3cret-should-not-be-in-global-table"
if err := ps.Set("password", secret); err != nil {
t.Fatalf("Set: %v", err)
}
// ① 全局表里查不到。
if v, _ := reg.Get("plugin.webui.password"); v != nil {
t.Errorf("插件配置泄漏进全局表:plugin.webui.password = %v", v)
}
// ② 插件表里能查到(证明 ① 不是「压根没写进去」)。
if v, _ := ps.Get("password"); v != secret {
t.Errorf("插件表里读不到刚写的值:%v", v)
}
// ③ 全局 List 也不该暴露它。
for _, k := range reg.List("") {
if strings.HasPrefix(k, "plugin.") {
t.Errorf("全局 List 暴露 plugin.* 形态的键 %q", k)
}
}
}
// TestGlobalConfigHasNoPluginPrefixedKeys 在**播种了全部默认值**之后检查全局表。
//
// ★ 与上一条同源但覆盖面更大:默认值播种(SeedDefaults)是「没人显式 Set
//
// 也会往库里写」的那条路径,最容易把带前缀的键漏进全局表。
func TestGlobalConfigHasNoPluginPrefixedKeys(t *testing.T) {
reg := newCfgRegistry(t)
for _, k := range reg.List("") {
if strings.HasPrefix(k, "plugin.") {
t.Errorf("SeedDefaults 后全局 config 表出现 plugin.* 形态的键 %q —— "+
"插件配置必须写进 config_<name> 表;写进全局表会把口令/密钥"+
"落到不该落的地方(生产曾残留 plugin.webui.password=admin123)", k)
}
}
}
// TestRegisterDefDoesNotQualifyKey 钉住 RegisterDef 的键限定行为:
//
// `PluginSettings.RegisterDef` 会把 `def.Key` 改写成 `plugin.<name>.<key>`
// 存进全局 defs 注册表(供设置页展示 meta),而**值**仍写 config_<name>。
// 这里确认「defs 注册表里有 plugin.* 键」是**预期**的,
// 免得后人把这条门禁误改成「全局不许出现 plugin. 前缀」而误伤正常元数据。
func TestRegisterDefDoesNotQualifyKey(t *testing.T) {
reg := newCfgRegistry(t)
ps := reg.PluginConfig("webui")
defs := ps.ListDefs("")
var sawQualified bool
for _, d := range defs {
if strings.HasPrefix(d.Key, "plugin.webui.") {
sawQualified = true
}
}
if !sawQualified {
// 没播种 webui 的 def 也可能为空 —— 那就不做断言,但要点明意图。
t.Log("本次 registry 无 webui 配置定义(SeedDefaults 未含),跳过正向确认")
}
}

View File

@ -0,0 +1,302 @@
package test
// 判据:「插件」页签的「服务入口」必须真的被请求到数据。
//
// ## 要判的是什么(2026-11-04 生产实测)
//
// 「插件」页签的「服务入口」卡片永远显示「暂无被反代的插件服务」,
// 而 /api/v1/proxy/services **一次都没被请求过**(journalctl 实测:
// 该端点只有我手工 curl 的那一条 401,用户正常使用期间零请求)。
//
// 根因:`renderPlugins()` 第一行就调 `renderProxyServicesCard()`,
// 而后者读 `state.proxyServices`;但 TABS 表里
//
// plugins: { fetch: ["status","kernel","plugins","runtime"], ... } ← 没有 proxyServices
//
// 于是 `state.proxyServices` 永远是 undefined ⇒ `svcs = []` ⇒ 空卡片。
// 只有先点进「对话」页(那里 fetch 了 proxyServices)再回「插件」才能看到。
//
// ## 为什么钉「三条事实」而不是做静态调用图
//
// 我第一版写了个递归调用图提取器(render 根 → 展开同文件调用 → 收集
// `state.X` 读点 → 比对 TABS.fetch)。它在实测中**完全失效**:
//
// 1. 中文注释里的引号(「」与 ')会破坏括号/字符串配平扫描,
// `escHtml` 的函数体被误判成覆盖到文件末尾 ⇒ renderAdapters 的
// "调用链"里出现 renderKernel / renderOneSettings / renderAll……
// 2. 分发器 `starmapFetchBlock` 的 switch 覆盖全部块名,展开它会让
// 任何调过它的 render 都被判成「缺十几块数据」。
// 3. 按需加载的 state(messages / chatOffset / starmapInit)不属于
// TABS.fetch,误报成缺口。
//
// 结论:**JS 的静态依赖分析在这个文件上不可靠**。可靠的是钉住本缺陷
// 的**结构性链条**——三件必须同时成立的事,每件都是一次廉价字符串检查:
//
// A. renderPlugins 确实调用了 renderProxyServicesCard
// B. renderProxyServicesCard 确实读 state.proxyServices
// C. TABS.plugins.fetch 确实含 "proxyServices"
//
// 任何一环断了都不会再有"空卡片"这种静默故障:A 断 ⇒ 卡片不再渲染
// (显而易见的 UI 变化,评审会看见);B 断 ⇒ 读不到数据(同样明显)。
// C 是本次故障的那一环,也是唯一纯粹"看不见"的一环。
//
// 更通用的依赖覆盖检查需要真正的 JS 语义分析(或在 harness 里记录
// fetch 调用序列并与页签切换做配对断言)。那属于测试补强,另行安排;
// 这里刻意只保留一条不会误报、不会假绿的判据。
import (
"strings"
"testing"
)
// funcBody 取顶层 `function name(` 的花括号体(配平扫描)。
//
// 只在**函数体内部**做配平,遇到不平衡就返回空串让调用方判失败——
// 宁可判「找不到」也不要基于错误范围给出假结论。
func funcBody(js, name string) string {
marker := "function " + name + "("
idx := strings.Index(js, marker)
if idx == -1 {
return ""
}
// 从开括号起配平参数表。marker 已包含那个 '(',所以深度从 1 起。
i := idx + len(marker)
depth := 1
for ; i < len(js); i++ {
switch js[i] {
case '(':
depth++
case ')':
depth--
}
if depth == 0 {
break
}
}
if i >= len(js) {
return ""
}
// 跳过返回类型/空白,定位函数体 '{'。
for ; i < len(js) && js[i] != '{'; i++ {
if js[i] == ';' || js[i] == '=' {
return "" // 不是声明,是别的形态
}
}
if i >= len(js) {
return ""
}
start := i
d := 0
inStr := byte(0)
for ; i < len(js); i++ {
c := js[i]
if inStr != 0 {
if c == '\\' {
i++
continue
}
if c == inStr {
inStr = 0
}
continue
}
switch c {
case '"', '\'', '`':
inStr = c
case '{':
d++
case '}':
d--
if d == 0 {
return js[start : i+1]
}
}
}
return ""
}
// TestProxyServicesReachesPluginsTab 是本次实测缺陷的回归判据。
//
// 变异验证:把 TABS.plugins.fetch 里的 "proxyServices" 删掉 ⇒ 立刻红。
func TestProxyServicesReachesPluginsTab(t *testing.T) {
js := dashboardJSSource(t)
// —— A. renderPlugins 调用 renderProxyServicesCard ——
plug := funcBody(js, "renderPlugins")
if plug == "" {
t.Fatal("找不到 renderPlugins 的函数体 —— 解析器与源码形态不同步,本判据此刻是假绿")
}
if !strings.Contains(plug, "renderProxyServicesCard(") {
t.Fatalf("renderPlugins 不再调用 renderProxyServicesCard()。\n" +
"若这是刻意移除,请同时删掉本判据;若是重构漏了,服务入口卡片就消失了。")
}
// —— B. renderProxyServicesCard 读取 state.proxyServices ——
card := funcBody(js, "renderProxyServicesCard")
if card == "" {
t.Fatal("找不到 renderProxyServicesCard 的函数体 —— 同上,假绿风险")
}
if !strings.Contains(card, "state.proxyServices") {
t.Fatalf("renderProxyServicesCard 不再读 state.proxyServices —— " +
"要么它换了数据源(本判据需同步),要么卡片会永远为空")
}
// —— C. TABS.plugins.fetch 含 proxyServices ——
tabs := parseTabs(t, js)
for _, tab := range tabs {
if tab.name != "plugins" {
continue
}
for _, f := range tab.fetch {
if f == "proxyServices" {
return // 三环皆成立
}
}
t.Fatalf("TABS.plugins.fetch 缺少 \"proxyServices\"。\n"+
"症状(2026-11-04 生产实测):「服务入口」卡片永远显示"+
"「暂无被反代的插件服务」,即使插件确实声明了 proxies ——"+
"因为 /api/v1/proxy/services 根本没被请求。\n"+
"renderPlugins 第一行调用 renderProxyServicesCard(),后者读 state.proxyServices;"+
"renderPlugins 是 TABS.plugins.render,所以 proxyServices 必须在该页签的 fetch 里。\n"+
"当前 fetch = %v", tab.fetch)
}
t.Fatal("TABS 里找不到 plugins 页签 —— 解析器与源码形态不同步,本判据此刻是假绿")
}
// tabsSpec 是从 dashboard.js 源码解析出的 TABS 条目。
type tabsSpec struct {
name string
fetch []string
renders []string
}
// parseTabs 用**花括号配平扫描**解析 TABS 对象。
//
// 为什么不用正则切条目(实测踩过):单行条目
// `starmap: { fetch: [], render: [...] },` 的右花括号在同一行,
// 非贪婪正则于是吞到**下一个**多行条目末尾 —— `starmap` 把紧随其后的
// `plugins` 整块吞进自己体内,症状是 plugins 页签"不存在"。
func parseTabs(t *testing.T, js string) []tabsSpec {
t.Helper()
start := strings.Index(js, "var TABS = {")
if start == -1 {
t.Fatal("找不到 var TABS")
}
openBrace := start + strings.Index(js[start:], "{")
// 配平 TABS 主体。
d := 0
end := -1
for i := openBrace; i < len(js); i++ {
switch js[i] {
case '{':
d++
case '}':
d--
if d == 0 {
end = i
}
}
if end >= 0 {
break
}
}
if end < 0 {
t.Fatal("TABS 花括号配平失败")
}
obj := js[openBrace+1 : end]
var out []tabsSpec
i := 0
for i < len(obj) {
// 跳过空白与逗号/换行,停在下一个标识符。
for i < len(obj) && (obj[i] == ' ' || obj[i] == '\t' || obj[i] == '\n' || obj[i] == '\r' || obj[i] == ',') {
i++
}
k := i
for k < len(obj) && (obj[k] == '_' || obj[k] >= 'a' && obj[k] <= 'z' ||
obj[k] >= 'A' && obj[k] <= 'Z' || obj[k] >= '0' && obj[k] <= '9') {
k++
}
if k == i {
i++
continue
}
name := obj[i:k]
r := k
for r < len(obj) && (obj[r] == ' ' || obj[r] == '\t' || obj[r] == '\n' || obj[r] == '\r') {
r++
}
if r >= len(obj) || obj[r] != ':' {
i = k
continue
}
r++
for r < len(obj) && (obj[r] == ' ' || obj[r] == '\t' || obj[r] == '\n' || obj[r] == '\r') {
r++
}
if r >= len(obj) || obj[r] != '{' {
i = k
continue
}
dd := 0
close_ := -1
for j := r; j < len(obj); j++ {
switch obj[j] {
case '{':
dd++
case '}':
dd--
if dd == 0 {
close_ = j
}
}
if close_ >= 0 {
break
}
}
if close_ < 0 {
t.Fatalf("条目 %q 花括号配平失败", name)
}
body := obj[r : close_+1]
spec := tabsSpec{name: name}
if f := quotedList(body, "fetch:"); f != nil {
spec.fetch = f
}
if rr := quotedList(body, "render:"); rr != nil {
spec.renders = rr
}
out = append(out, spec)
i = close_ + 1
}
if len(out) == 0 {
t.Fatal("TABS 解析出 0 个页签 —— 解析器与源码形态不同步")
}
return out
}
// quotedList 取 `key: ["a", "b"]` 里的字符串列表。
func quotedList(body, key string) []string {
idx := strings.Index(body, key)
if idx == -1 {
return nil
}
lb := strings.Index(body[idx:], "[")
if lb == -1 {
return nil
}
lb += idx
rb := strings.Index(body[lb:], "]")
if rb == -1 {
return nil
}
inner := body[lb+1 : lb+rb]
var out []string
for _, p := range strings.Split(inner, ",") {
p = strings.Trim(strings.TrimSpace(p), `"`)
if p != "" {
out = append(out, p)
}
}
return out
}

View File

@ -0,0 +1,99 @@
package test
// 本文件是 test 包的共享测试辅助(不属于任何单个判据)。
//
// 放在这里而不是某个 *_test.go 里:多个判据文件都要用它,
// 而在"测试与源码分离"之后,它们不能再从包内符号借用。
import (
"strings"
"testing"
"github.com/JianFeeeee/HologramAgent/internal/plugins/webui"
)
// stripJSComments 剥掉 // 行注释与 /* */ 块注释(粗粒度,但足够用于词频统计)。
//
// 不做完整的 JS 词法分析:本判据只用于「统计某个标识符出现次数」,
// 粗粒度剥离已经足够,且不会误伤字符串里的 //(本文件没有这类内容,
// 若将来有,宁可少剥也不能多剥 —— 多剥会让判据漏报)。
func stripJSComments(s string) string {
var b strings.Builder
b.Grow(len(s))
inLine, inBlock := false, false
var inStr byte
for i := 0; i < len(s); i++ {
c := s[i]
if inLine {
if c == '\n' {
inLine = false
b.WriteByte(c)
}
continue
}
if inBlock {
if c == '*' && i+1 < len(s) && s[i+1] == '/' {
inBlock = false
i++
}
continue
}
if c == '\n' {
b.WriteByte(c)
continue
}
if inStr != 0 {
b.WriteByte(c)
if c == '\\' {
if i+1 < len(s) {
b.WriteByte(s[i+1])
i++
}
continue
}
if c == inStr {
inStr = 0
}
continue
}
if c == '"' || c == '\'' || c == '`' {
// 反引号也是字符串定界符,但它可跨行 —— 本判据只统计标识符,
// 跨行模板串里出现标识符属正常,此处按普通字符串处理即可。
inStr = c
b.WriteByte(c)
continue
}
if c == '/' && i+1 < len(s) && s[i+1] == '/' {
inLine = true
i++
continue
}
if c == '/' && i+1 < len(s) && s[i+1] == '*' {
inBlock = true
i++
continue
}
b.WriteByte(c)
}
return b.String()
}
// dashboardJSSource 取出待测的前端脚本原文。
//
// 经导出的 webui.FrontendAsset 读 —— 与"资源存在哪个变量、怎么 embed"
// 完全无关,测试只关心「实际会被发出去的那份文本」。
func dashboardJSSource(t *testing.T) string {
t.Helper()
b, err := webui.FrontendAsset("dashboard.js")
if err != nil {
t.Fatalf("读 dashboard.js: %v", err)
}
return string(b)
}
// dashboardJS 与 dashboardJSSource 同义,保留两个名字是因为
// 原来的判据文件各自用其中一个(迁移时不做无谓重命名)。
func dashboardJS(t *testing.T) string {
t.Helper()
return dashboardJSSource(t)
}

View File

@ -0,0 +1,72 @@
package test
import (
"strings"
"testing"
)
// 插件列表「启动状态」渲染的回归判据(2026-10-20 生产反馈)。
//
// ## 原始问题
//
// 面板只拿内核 status 的 loaded 一个布尔来判断,于是三种完全不同的形态
// 被显示成同一个「未加载」:
//
// · auto_start=false 且未运行 → **按需插件正常待命**(不是故障)
// · auto_start=true 且未运行 → 该起没起(需要关注)
// · 运行态 plugin_stop 之后 → 用户自己停的(重启会恢复)
//
// 用户看到一屏「未加载」,以为插件全坏了 —— 实际它们只是在等 plugin_start。
//
// ## 判据思路
//
// 与 cmd_history_render_test.go 同款:不执行 JS,只守住源码结构 ——
// 渲染函数必须消费 auto_start / runtime_stopped / alive 三个字段,
// 并给出「按需待命」这一独立分支;否则回归立刻红。
func TestPluginStatusUsesRuntimeFields(t *testing.T) {
src := dashboardJSSource(t)
start := strings.Index(src, "function renderPlugins()")
if start < 0 {
t.Fatal("找不到 renderPlugins —— 函数被重命名或删除,请同步更新本判据")
}
end := strings.Index(src[start+1:], "\nfunction ")
var body string
if end < 0 {
body = src[start:]
} else {
body = src[start : start+1+end]
}
for _, want := range []string{
"auto_start", // 区分自启/按需
"runtime_stopped", // 区分运行态停止与持久禁用
"alive", // 区分「已加载」与「进程丢失」
"按需待命", // 按需插件待命是独立状态,不能混进「未运行」
} {
if !strings.Contains(body, want) {
t.Fatalf("renderPlugins 未引用 %q —— 状态判定退化,"+
"按需插件会被误显示成故障。见本文件头部说明", want)
}
}
}
// 「启动」按钮必须走运行态接口 /start,不能复用持久态的 /enable ——
// 后者会清掉持久禁用标记,让「点一下启动」变成「以后都自启」。
func TestPluginStartUsesRuntimeEndpoint(t *testing.T) {
src := dashboardJSSource(t)
i := strings.Index(src, "async function startPlugin(")
if i < 0 {
t.Fatal("找不到 startPlugin")
}
body := src[i:]
if j := strings.Index(body[1:], "\nasync function "); j > 0 {
body = body[:j+1]
}
if !strings.Contains(body, `"/plugins/" + encodeURIComponent(name) + "/start"`) {
t.Fatal("startPlugin 必须调用 /plugins/<name>/start(运行态)," +
"不得复用 /enable(持久态会改动开机行为)")
}
if strings.Contains(body, "\"/enable\"") {
t.Fatal("startPlugin 里出现了 /enable —— 运行态启动不得动持久禁用清单")
}
}

View File

@ -0,0 +1,173 @@
package test
import (
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
// 星图渲染必须避免「每帧全量」——这些判据钉住 2026-10-08 修「非常卡」的三处。
//
// 为何用源码断言而不是运行时测量:dashboard.js 是嵌入的浏览器脚本,
// 用 Node 跑不了(依赖 three.js / DOM)。而这三条都是**结构性**缺陷
// (循环扫全表、循环不停、每帧重建),源码里能可靠判定,且回归时立刻红。
func TestStarmapNoPerFrameFullScan(t *testing.T) {
src := readDashboardJS(t)
// ① starmapTickMorph 不得再全量遍历 starmapNodeMeshes。
// 原先 3190 节点 × 60fps = 每秒 19 万次 userData 读取与分支,
// 而绝大多数帧里「正在形变」的节点是 0 个。
morph := extractFunc(t, src, "starmapTickMorph")
if strings.Contains(morph, "starmapNodeMeshes") {
t.Error("starmapTickMorph 又全量遍历 starmapNodeMeshes 了:应只遍历 starmapMorphSet 活跃集," +
"空集时 O(1) 返回(否则每帧 3190 次无谓遍历)")
}
if !strings.Contains(morph, "starmapMorphSet") {
t.Error("starmapTickMorph 必须走 starmapMorphSet 活跃集合")
}
if !regexp.MustCompile(`starmapMorphSet\.size\s*===\s*0`).MatchString(morph) {
t.Error("starmapTickMorph 必须在活跃集为空时提前返回(这是常见路径,应当零开销)")
}
// ② 渲染循环必须能被暂停(否则切走页签/后台仍 60fps 空转)。
anim := extractFunc(t, src, "starmapAnimate")
if !strings.Contains(anim, "starmapAnimateRunning") {
t.Error("starmapAnimate 缺少 starmapAnimateRunning 开关:切走页签后循环不会停")
}
if !strings.Contains(anim, "starmapActiveContainer") {
t.Error("starmapAnimate 必须按「星图容器是否可见」判断暂停," +
"不能用 document.hidden(星图可能在未激活页签里被搬走)")
}
// ③ 重建图时必须清空形变活跃集,否则集合单调增长、
// 「空集 O(1)」快速路径永不生效,每帧还遍历已废弃 mesh。
// 断言必须在**重建函数体内**且真的在调用 clear(不能只看全仓有这串,
// 否则改成 `if (false) ...clear()` 也能骗过——变异测试实测过)。
rebuild := extractFunc(t, src, "buildChatStarmapGraph")
if !regexp.MustCompile(`(?m)^\s*if \(starmapMorphSet\) starmapMorphSet\.clear\(\);`).MatchString(rebuild) {
t.Error("buildChatStarmapGraph 必须无条件清空 starmapMorphSet(`if (starmapMorphSet) starmapMorphSet.clear();`):" +
"旧 mesh 已从场景摘掉,却会留在活跃集里被永远遍历")
}
// ④ 标签层必须有节流(相机拖动时 camKey 每帧都变 → 否则每帧重建全量 plan)。
labels := extractFunc(t, src, "starmapDrawLabels")
if !strings.Contains(labels, "SM_LABEL_MIN_INTERVAL") {
t.Error("starmapDrawLabels 缺少节流:OrbitControls 有阻尼,拖动时每帧重建 3190 项 plan")
}
// ⑤ ★★ 2026-10-13:力导向布局必须是网格近邻(O(n·k)),不得再出现
// 全对全(O(n²))的双层循环。
//
// 为何单独钉这一条:生产库 3754 块时全对全 50 轮 = 3.52 亿次配对,
// CDP Profiler 实测单次阻塞主线程 10.9 秒。而斥力体里本来就有
// `if (d < 25)` —— 只对近邻施力,没必要全对全。
//
// ★ 判据必须同时覆盖**两处**实现(串行回退 + Worker 脚本):
// 只盯着 dashboard.js 的话,把回退改回全对全就没人拦 ——
// 而那正是“有 Worker 与没 Worker 看到的图不一样”的成因。
relax := extractFunc(t, src, "starmapLayoutRelax")
if !strings.Contains(relax, "grid") || !strings.Contains(relax, "CUTOFF") {
t.Error("starmapLayoutRelax 必须走网格近邻(grid + CUTOFF):" +
"全对全斥力在生产库(3754 块)实测阻塞主线程 10.9 秒")
}
// 全对全的特征:内层循环遍历外层指针的副本(Object.keys(pos) / ids.length)
if strings.Contains(relax, "Object.keys") {
t.Error("starmapLayoutRelax 又回到 Object.keys 全量遍历:那是 O(n²) 的写法")
}
if !strings.Contains(src, "starmapLayoutIterationsFor") {
t.Error("缺少迭代数自适应(starmapLayoutIterationsFor):" +
"固定 50 轮在大图上绝大多数轮次是在重复收敛后的状态")
}
// ★★ 布局代码必须**与 GUI 同源**(2026-10-13)。
//
// 为何单独钉这一条:这份布局曾在 dashboard.js 与 cmd/gui/renderer/app.js
// 各存一份手抄。两次真实事故都出自这个分叉:
// ① WebUI 换上网格法后 GUI 那份没跟着改(换客户端卡顿原样存在)
// ② 反向抄时抄了 WebUI 独有的 starmapMorphSet,GUI 用 state.starmapGrown
// ⇒ 渲染循环每帧 ReferenceError、星图 0 帧
// 现在唯一事实源在 shared/starmap/layout.js,由 tools/starmap-sync.mjs
// 写进两处标记块。这里校验「本文件里的块 == 共享源」,手改会被立刻拦下。
checkStarmapSharedBlock(t, src)
// Worker 脚本必须存在且用同一套网格算法
wb, err := os.ReadFile(filepath.Join("..", "static", "starmap-layout-worker.js"))
if err != nil {
t.Fatalf("缺少 static/starmap-layout-worker.js: %v", err)
}
wsrc := string(wb)
if !strings.Contains(wsrc, "CUTOFF") || !strings.Contains(wsrc, "iterationsFor") {
t.Error("starmap-layout-worker.js 必须用同一套网格法(CUTOFF)与自适应迭代数," +
"否则 Worker 与串行回退会给出不同布局")
}
if regexp.MustCompile(`for \(var j = i \+ 1; j < ids\.length`).MatchString(wsrc) {
t.Error("starmap-layout-worker.js 里仍有全对全双层循环(O(n²))")
}
// ⑥ 建图必须在容器可见时才做(否则聊天页签也被拖死)。
render := extractFunc(t, src, "renderChatStarmap")
if !strings.Contains(render, "offsetParent") {
t.Error("renderChatStarmap 必须在容器不可见时提前返回(offsetParent 判据):" +
"starmapActiveContainer 会 fallback 到聊天面板,使未打开星图也被迫建图")
}
}
// extractFunc 取一个顶层函数的源码体(到下一个行首 `}` 为止)。
func extractFunc(t *testing.T, src, name string) string {
t.Helper()
marker := "function " + name + "("
i := strings.Index(src, marker)
if i < 0 {
t.Fatalf("找不到函数 %s", name)
}
rest := src[i:]
end := strings.Index(rest, "\n}\n")
if end < 0 {
return rest
}
return rest[:end]
}
// readDashboardJS 经导出的 webui.FrontendAsset 读嵌入资源
// (原先读磁盘相对路径 "dashboard.js",迁移到 test 包后失效)。
func readDashboardJS(t *testing.T) string {
t.Helper()
return dashboardJSSource(t)
}
// checkStarmapSharedBlock 校验本文件里的共享块与 shared/starmap/layout.js 逐字节一致。
//
// 判据读宿主文件 + 共享源 + 生成器三者约定:标记块内容 = 生成器产出的那一整段。
// 用一个薄封装调生成器(--check)比在 Go 里重算拼接规则更可靠 ——
// 拼接规则只有一处(tools/starmap-sync.mjs),重复实现它正是「两份副本」的老毛病。
func checkStarmapSharedBlock(t *testing.T, src string) {
t.Helper()
const begin = "// ===== BEGIN shared:starmap/layout ====="
const end = "// ===== END shared:starmap/layout ====="
b := strings.Index(src, begin)
e := strings.Index(src, end)
if b < 0 || e < 0 || e < b {
t.Fatalf("找不到共享块标记(%s / %s)—— 星图布局必须由 shared/starmap/layout.js 生成",
begin, end)
}
block := src[b : e+len(end)]
// 共享源必须被完整包进块里(抽首尾两段特征串判,避免把整份源码抄进断言)。
shared, err := os.ReadFile(filepath.Join("..", "..", "..", "..", "shared", "starmap", "layout.js"))
if err != nil {
t.Fatalf("读共享源失败: %v", err)
}
sh := strings.TrimRight(strings.ReplaceAll(string(shared), "\r\n", "\n"), "\n")
// 共享源的正文(去掉首尾空行)必须逐字出现在块内。
body := strings.TrimSpace(sh)
if !strings.Contains(block, body) {
t.Error("本文件的星图布局块与 shared/starmap/layout.js 不一致(漂移)。\n" +
" 修法:node tools/starmap-sync.mjs\n" +
" 若是改算法,请只改 shared/starmap/layout.js,再跑同步 ——\n" +
" 直接改这个副本会让 WebUI 与 GUI 再次分叉(已因此出过两次事故)。")
}
if !strings.Contains(block, "generated") && !strings.Contains(block, "生成") {
t.Error("共享块内应带「本块由生成器产出」的提示,避免后人当普通代码手改")
}
}