Files
HomeAgent/internal/plugins/webui/cmd_history_render_test.go
root 835688c94a refactor(plugins): 按「内置插件判定标准」收敛内置清单 16 → 11
## 判定标准(写进 all.go 与 ARCHITECTURE)

**只有必须使用内部 SDK 能力(公开 SDK 没有的接口面)的插件,才留在
编译期内置清单**;其余一律外置为 .hmap 插件,独立编译、独立发版。

依据是 internal/plugin/proc/capability.go 的 withheldCapabilities:
内部 SDK 的 TerminalAPI/TrackerAPI/StatusAPI/SupervisorAPI/AdapterAPI/
SelftestAPI/ToolAPI/Publish 等能力面,外部插件在类型层就不存在。
需要它们的插件外置后功能会**静默消失**(不报错),必须内置;
只要公开能力的插件,留在内核里是白付重编成本。

## 内置 11 个(all.go 单一权威清单)

  cli webui       TerminalAPI/Tracker/Status/Supervisor/Adapter
  agentcli        EventPublish(终端输出走 Publish,公开 SDK 无此方法)
  healthcheck     SelftestAPI
  pluginmgr       failback 恢复链必需
  seq             ToolAPI
  remotedevice    RegisterChannel
  clawhubadapter  InjectInputSync
  cfgmgr skillmgr seemedia(保留)

## 外置 8 个(源码保留,.hmap 由 SDK 仓 example/ 提供)

  timer(此前)ai_image files cmd kbtree localuse mcp multimodal

## 同步修正

- kbtree 归位:它一直是编译期内置插件,却长期被 main.go 单独导入,
  游离在 all.go 清单之外(文档盘点因此数错)。现移入 all.go,
  main.go 不再单独导入任何插件,并加注释禁止该做法。
- integration_test:外置插件的 7 个用例(cmd_run/terminal_*)加
  requireExternalTool 跳过保护——用例保留(行为判据仍有价值),
  在纯内置装配下 Skip,不造假失败。
- 全部文档同步 11 个的清单与判定标准(中英四份 + 两份 README)。

## 已核实不做的事

- 保留的 11 个内置插件无一在代码里硬依赖被外置的插件:
  cli 对 terminal_* 是 ExecuteTool 按名动态调用(运行时依赖);
  healthcheck/seq 只在白名单与示例文本里出现;webui 只订阅事件。
- kernelmod/(内核模块)与 plugins/(插件)严格区分:
  kernelmod/multimodal 是内核装配模块,与外置的 multimodal 插件无关。

验证:go build ./... 全绿;go test ./internal/plugins/... 全绿;
webui 新增 5 条命令历史渲染回归判据(TestCmdHistory*)全绿。
2026-10-19 21:13:23 +08:00

131 lines
5.9 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 (
"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 —— 数据源被换掉时必须同步更新本判据")
}
}