Files
HomeAgent/internal/plugins/webui/handler_files_contract_test.go
JianFeeeee 7cef7df6f2 test: 判据改为面向接口输入输出 + 文档立规 + CI 补缺口
本提交是三件事的第一批,后续按计划分步推进。

## 一、判据改造(两条实证)

### 1. turn_hint 判据原是自证循环

原测试在测试里**手动重放**了真实注入逻辑:

    f.Msgs = a.buildMessages(...)
    hint := stageHint(f.StageCtx, "turn_hint")
    f.Msgs = append(f.Msgs, Message{Role:"system", Content:"[Turn hints] "+hint})
    // 然后断言"最后一条是 system、内容含 [Turn hints]"

它断言的是**它自己刚 append 的那一行** —— 把 task.go:656-661 抄进测试
再验证抄得对不对。真实代码那段删掉,它照样绿。

改为:把 turn_hint 放进 StageCtx,跑**真实的 stepPrepare**
(newTaskFrame 起始就是 StepPrepare),检查它产出的 Msgs。

变异验证:删掉 task.go 里 `if hint := stageHint(...)` 那段 ⇒ 实测红。

### 2. webui 文件下发判据原是白盒

原测试直接调未导出的 `stageWebFile(src, isImage)`、断言 `webFilesDir`
包级变量 —— 那都是**实现方式**。

改为从**输出通道 handler** 走(`RegisterOutputChannel("webui", …)`,
agent 发图/发文件时走的就是它):

    输入:{payload: "<路径或URL>", type: "image"|"file"}
    输出:{status:"ok", url:"/files/<name>.<ext>", size:N}

4 条判据(新版 handler_files_contract_test.go):
  · 图片 → 可下载 URL(前缀/扩展名/size)
  · 远端 URL 原样透传(不下载)
  · 危险扩展名被中和(.html 保留会在 webui 域执行脚本)
  · 空 payload 被拒

变异验证:丢扩展名 ⇒ 红;远端也拷贝 ⇒ 红。

## 二、文档立规:engineering-lessons 新增第五节

原来只有「判据会骗人」,没有「判据本身形状错了」。新增 6 小节:

    5.1  判据必须把被测模块当黑箱(含"如果重写内部实现、接口不变,
         这条判据还会绿吗"这个可操作判据)
    5.2  禁止读源码做断言(附正确替代表)
    5.2.1 判据不得以文档为依据
    5.3  测试文件与业务代码分离(现状:521 文件 / 11.2 万行,
         agent/core 一个包 196 文件里 144 个是测试)
    5.4  CI 必须覆盖每个独立 module
    5.5  CI 与本地必须跑同一套门

§5.2.1 的直接触发:`interrupt_invariant_test.go` 里有一条判据写着
「事实清单(来自探测,见 docs/zh/interrupt-subsystem-split-plan.md §1.5)」
—— 被引用方自己标着「状态:计划(未实施)」,而那条判据的实际断言
只是"入队后队列里 1 条",与声称的"反向依赖现状快照"毫无关系。
该判据已删除(入队行为已由 scheduler_preempt_test 的黑箱断言覆盖)。

## 三、CI 补两个缺口

### gofmt 门

提交纪律要求 `gofmt -l` 干净,但三个 workflow 里 `gofmt` 出现 **0 次**
—— 纪律只靠人自觉。补上后未格式化的代码进不了主干。

只查本仓自管目录:SDK 是独立仓,格式由它自己的 CI 管。

### SDK 判据步骤

`third_party/hologramagentsdk/` 有自己独立的 go.mod:

    go list ./... | grep third_party   →  0 个

主仓 `go test ./...` **永远不包含** SDK 任何测试。实测 SDK 有 30 个判据
文件,主仓 CI 一次都没跑过。

补的步骤只跑主仓 git 跟踪的文件(CI 上只有 22 个 vendored 文件,
工作机上是完整独立仓)—— 实测 `git archive` 出 22 个文件后
`go test ./sdk/ ./meta/` 全绿。

## 四、SDK 仓测试迁移(独立仓,已单独提交 3a38ef5)

`sdk/{plugin,proxy,stress}_test.go` → `sdk/test/`,包名 `sdk` → `test`。

关键:原测试直接写 `&PluginSDK{regStage: X, name: "test"}` —— **私有字段**。
而 `New(name, sett, regTool, regStage, regAPI, regOutput)` 本来就把所有
registrar 作为参数,等价且是生产路径。共改 19 处,0 处剩余。

主仓同步更新了跟踪路径(这三个文件主仓也 vendor 了一份)。

## 验证

    go build ./...            PASS
    go vet ./...              PASS
    gofmt -l                  干净
    go test ./... -count=1    62 包全过,0 FAIL
    干净检出(worktree)       build + 改造后的判据全绿

## 后续(按计划分步)

  步骤 2  webui 29 个测试分类迁移(已实测出精确分类)
  步骤 3  agent/core 739 处白盒引用先分类再动
  步骤 4  SDK tools/hmapdev(package main,无法迁出,只加说明)
  步骤 5  CI 补 coverage + staticcheck
  步骤 6  文档双语(需定术语口径)
2026-11-04 18:11:06 +08:00

173 lines
6.3 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
// webui 文件下发能力的**契约判据**:面向输入 → 输出,不面向内部流程。
//
// ## 为什么重写(2026-11-04)
//
// 原判据(handler_files_test.go)直接调未导出的 `stageWebFile(src, isImage)`,
// 断言它返回的 url 前缀、size、以及中转文件是否落盘。问题:
//
// ① 它测的是**内部函数的形状**。`stageWebFile` 是"怎么实现",
// 不是"对外承诺什么"。改成分块上传、改成流式、改成存对象存储,
// 只要 webui 通道仍然返回可下载的 url,外部行为就没变 —— 但判据会红。
// ② 它断言 `webFilesDir` 这个**包级变量**,那是实现细节。
// ③ 它没有走真实的调用链:真正入口是**输出通道 handler**
// (plugin.go RegisterOutputChannel("webui", …)),
// agent 发图/发文件时走的就是它。
//
// 所以本判据改为:**通过输出通道 handler 喂输入,断言返回的输出**。
//
// 输入:{payload: "<本地路径或 URL>", type: "image" | "file"}
// 输出:{status: "ok", url: "/files/<名字>.<ext>", size: N}
//
// 这样断言的是**通道契约**:agent 交一个文件,webui 交回一个可访问的 URL。
// 实现怎么中转(拷贝 / 硬链 / 对象存储)都不影响这条判据。
//
// ## 判据的边界
//
// 这里不验证 `/files/` 的鉴权 —— 那是 handler 层的契约,
// 由 handler 的黑箱判据覆盖(见 handler_files_download_test.go)。
import (
"net"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
internalConfig "github.com/JianFeeeee/HologramAgent/internal/config"
"github.com/JianFeeeee/HologramAgent/internal/events"
"github.com/JianFeeeee/HologramAgent/internal/sdk"
)
// captureWebUIOutput 起一个 webui 插件,截获它的 "webui" 输出通道 handler。
//
// 这是**测试装配**,不是绕过生产路径:生产里由内核的通道注册表接管
// (regOutput),这里换成捕获函数,为的是拿到真正被注册的 handler 本身。
func captureWebUIOutput(t *testing.T) (handler sdk.ToolHandler, filesDir string) {
t.Helper()
filesDir = t.TempDir()
webFilesDir = filesDir
t.Cleanup(func() { webFilesDir = "" })
cfgReg := internalConfig.NewConfigRegistry(filepath.Join(t.TempDir(), "config.db"))
t.Cleanup(func() { cfgReg.Close() })
seedWebUIConfig(cfgReg)
// 绑空闲端口::8080 是生产端口(见 a752ae1 的教训)。
probe, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
addr := probe.Addr().String()
probe.Close()
cfgReg.PluginConfig("webui").Set("addr", addr)
var captured sdk.ToolHandler
s := testSDK(sdk.SDKConfig{
Settings: sdk.NewSettings("webui", cfgReg),
EventBus: events.NewBus(),
RegOutput: func(name string, caps int, desc string, def sdk.ChannelDef, h sdk.ToolHandler) error {
if name == "webui" {
captured = h
}
return nil
},
})
p := &Plugin{name: "webui", mux: http.NewServeMux()}
if err := p.Start(s); err != nil {
t.Fatalf("启动 webui 插件失败: %v", err)
}
t.Cleanup(func() { _ = p.Stop() })
if captured == nil {
t.Fatal("★ webui 没有注册输出通道 handler —— 文件下发能力不存在")
}
return captured, filesDir
}
// TestOutputChannelImagesReturnDownloadableURL 是主判据:图片 → 可下载 URL。
//
// 变异:让 stageWebFile 对图片返回空 url ⇒ 本例红。
func TestOutputChannelImagesReturnDownloadableURL(t *testing.T) {
handler, _ := captureWebUIOutput(t)
src := filepath.Join(t.TempDir(), "photo.jpg")
body := "fake-jpeg-bytes"
if err := os.WriteFile(src, []byte(body), 0644); err != nil {
t.Fatal(err)
}
out, err := handler(map[string]interface{}{"payload": src, "type": "image"})
if err != nil {
t.Fatalf("发图片失败: %v", err)
}
m := out.(map[string]interface{})
if m["status"] != "ok" {
t.Fatalf("status = %v,期望 ok", m["status"])
}
url, _ := m["url"].(string)
if !strings.HasPrefix(url, "/files/") {
t.Fatalf("★ url = %q —— 前端按 /files/ 前缀加载,换前缀就点不开", url)
}
if !strings.HasSuffix(url, ".jpg") {
t.Fatalf("★ url = %q —— 扩展名丢了,浏览器不会按图片渲染", url)
}
if size, _ := m["size"].(int64); size != int64(len(body)) {
t.Fatalf("size = %v,期望 %d(前端用它显示文件大小)", m["size"], len(body))
}
}
// TestOutputChannelRemoteURLPassesThrough 钉住:远程 URL 原样返回、不下载。
//
// 语义:agent 已经有一个公网可访问的图片,不需要中转。
// 变异:改成无条件拷贝 ⇒ 本例红(会返回 /files/ 前缀)。
func TestOutputChannelRemoteURLPassesThrough(t *testing.T) {
handler, _ := captureWebUIOutput(t)
const remote = "https://example.com/a.png"
out, err := handler(map[string]interface{}{"payload": remote, "type": "image"})
if err != nil {
t.Fatalf("远端 URL 应放行,实际报错: %v", err)
}
url, _ := out.(map[string]interface{})["url"].(string)
if url != remote {
t.Fatalf("★ 远端 URL 被改写为 %q —— 浪费带宽且有 SSRF 风险", url)
}
}
// TestOutputChannelDangerousExtensionIsNeutralized 钉住安全契约。
//
// 一个 .html 文件以图片身份下发时,若保留 .html,浏览器会在 webui 域下
// **执行它的脚本**(存储型 XSS)。所以非白名单扩展名必须被改写。
//
// 变异:删掉扩展名改写 ⇒ 本例红。
func TestOutputChannelDangerousExtensionIsNeutralized(t *testing.T) {
handler, _ := captureWebUIOutput(t)
src := filepath.Join(t.TempDir(), "evil.html")
if err := os.WriteFile(src, []byte("<script>alert(1)</script>"), 0644); err != nil {
t.Fatal(err)
}
out, err := handler(map[string]interface{}{"payload": src, "type": "file"})
if err != nil {
t.Fatalf("发文件失败: %v", err)
}
url, _ := out.(map[string]interface{})["url"].(string)
if strings.HasSuffix(url, ".html") {
t.Fatalf("★ html 以 .html 后缀下发(%q)—— 会在 webui 域下执行脚本", url)
}
}
// TestOutputChannelEmptyPayloadIsRejected 钉住输入校验。
//
// 变异:允许空 payload ⇒ 本例红(会生成一个指向空的 URL)。
func TestOutputChannelEmptyPayloadIsRejected(t *testing.T) {
handler, _ := captureWebUIOutput(t)
if _, err := handler(map[string]interface{}{"payload": "", "type": "image"}); err == nil {
t.Fatal("★ 空 payload 被接受 —— 会产出一个打不开的 URL")
}
}