Files
HomeAgent/internal/plugins/webui/test/dashboard_tabs_deps_test.go
JianFeeeee 87f874e06f 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
2026-11-04 18:17:31 +08:00

303 lines
8.8 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 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
}