Files
HomeAgent/internal/plugins/webui/test/chat_ack_timeout_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

99 lines
3.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-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 样式展示(这不是错误)")
}
}