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,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
}