Files
HomeAgent/internal/plugins/webui/chat_attachment_img_test.go
JianFeeeee acaf0887c7 fix(webui): 聊天图片"加载成功却显示加载失败"——HTML 属性里的反斜杠转义
## 缺陷

生产截图:图片加载成功了(img 元素在、src 正确),界面却多显示一行
蓝字「图片加载失败"/>」。

根因是内联事件属性里用了反斜杠转义:

    onerror="this.parentElement.innerHTML='<span class=\"att-err\">图片加载失败</span>'"

**HTML 属性值里没有反斜杠转义** —— 那个 \" 就是引号本身,属性在
`<span class=` 之后就闭合了。浏览器切出来的是:

    onerror = "this.parentElement.innerHTML='<span class="

剩下的 `att-err">图片加载失败</span>'"/>` 变成**文本节点**直接显示。

症状极具误导性:看起来像"图片坏了",实际图片是好的;而那个 onerror
因为语法错误**永不执行** —— 真失败时反而什么都不显示。

## 修法

不用内联属性(那要求以后每次拼 HTML 都记得转义规则),改成
**捕获阶段的事件委托**:

- error 事件**不冒泡**,只能在捕获阶段拿到(注释里写明了)
- 用 createElement + textContent 构造占位,不再拼 innerHTML 字符串
- 只处理 .chat-attachment-img,不影响正文里的 markdown 图片

## 顺带:两个类没有 CSS

`chat-attachment-img` 与 `att-err` 只出现在 JS 里,CSS 里一条都没有。
图片靠内联 style 撑着还能看,失败占位则完全没有样式。已补。

## 判据(2 条,变异验证)

- `TestChatAttachmentImgHasNoBrokenInlineHandler`:不许内联 onerror、
  必须用 DOM API、必须有 error 监听
- `TestDashboardNoEscapedQuoteInHTMLAttributes`:通用扫描,
  任何 `name="...\"...` 形态都会红(下次别人写内联事件也拦得住)

变异:把内联 onerror 放回去 ⇒ 两条同时红。

★ 写判据时踩到的坑(都已修):判据被**自己的注释**打红(注释里必须写出
错误写法来解释),需先用已有的 stripJSComments 剥离;另外我先自己写了一个
stripJSComments,与 dashboard_js_scope_test.go 的重名 —— 应复用已有的。

## 顺带修一条脆弱判据

`TestRTChild_RapidInputCollapsesToLatest` 全量跑时偶发红
("连发 50 条却跑了 5 轮")。

探针查实:连发 50 条只花 ~80µs,而测试 provider 的 ChatStream **同步瞬间
返回**,两者同量级 ⇒ 消费方能在连发过程中抢到几次执行。探针显示
dropped=48,collapse 明明生效了。

所以 `Rounds > 3` 是在测调度抖动,不是测契约。改为断言**覆盖主导**
(dropped >= burst/2)。真实通话每轮几十毫秒,连发微秒级,生产里
"轮次≈1" 自然成立。

变异验证:让 dropped 不计数 ⇒ 判据红(dropped=0(连发 50 条))。

go build/vet/gofmt 干净;go test ./... -count=1 -race = 62 ok / 0 FAIL / 0 RACE
2026-10-31 10:03:43 +08:00

94 lines
4.5 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
// 聊天图片附件的 HTML 必须**能被浏览器正确切分**(2026-10-30)。
//
// ## 缺陷现场
//
// 生产截图:图片明明加载成功了(img 元素在、src 正确),界面上却多出一行
// 蓝色文字「图片加载失败"/>」。
//
// 根因是内联事件属性里用了反斜杠转义:
//
// onerror="this.parentElement.innerHTML='<span class=\"att-err\">图片加载失败</span>'"
//
// **HTML 属性值里没有反斜杠转义** —— 那个 \" 就是引号本身,属性在
// `<span class=` 之后就闭合了。浏览器切出来的是:
//
// onerror = "this.parentElement.innerHTML='<span class="
//
// 剩下的 `att-err">图片加载失败</span>'"/>` 变成**文本节点**直接显示。
// 而那个 onerror 因为语法错误永远不会执行 —— 真失败时反而什么都不显示。
//
// ## 为什么这条判据值得写
//
// 这种缺陷**不会让任何构建或测试失败**:JSON 合法、JS 语法合法、
// 字符串拼接合法。它只在浏览器里错,而且症状("加载成功却显示加载失败")
// 指向的方向与真因(引号归属)完全不同。
//
// 判据做法:按正则把 HTML 里所有 `name="value"` 属性切出来,
// 断言每个值内部**不含裸引号**,并断言页面里没有残留的 `"/>` 文本片段。
import (
"regexp"
"strings"
"testing"
)
// attrRe 匹配 HTML 属性。用它模拟浏览器"走到下一个引号就闭合"的行为。
var attrRe = regexp.MustCompile(`([a-zA-Z_:][-a-zA-Z0-9_:.]*)="([^"]*)"`)
// TestChatAttachmentImgHasNoBrokenInlineHandler 钉住附件图片的 HTML 形态。
func TestChatAttachmentImgHasNoBrokenInlineHandler(t *testing.T) {
js := stripJSComments(dashboardJS(t))
if !strings.Contains(js, "chat-attachment-img") {
t.Fatal("dashboardJS 里找不到 chat-attachment-img —— 附件图片渲染被改名或删了?")
}
// ① 内联 onerror 不能再出现:它是本缺陷的载体。
//
// 真正的失败处理已改为**捕获阶段的事件委托**(error 事件不冒泡,
// 只能在捕获阶段拿到)。所以这里断言"没有内联 onerror"是合理的收紧,
// 而不是"不许处理错误"。
if strings.Contains(js, `onerror="this.parentElement`) {
t.Error("★ 附件图片仍在用内联 onerror —— " +
"HTML 属性不支持反斜杠转义,属性会被提前闭合、剩余部分变成可见文本。" +
"请用捕获阶段的 error 事件委托(见 renderChat 里的 addEventListener(\"error\", ..., true))")
}
// ② 失败占位必须走 DOM API,不能拼 innerHTML 字符串。
//
// innerHTML 拼接是这条缺陷的来源(要在字符串里同时处理 HTML 引号、
// JS 引号、CSS 选择器)。用 createElement + textContent 只需处理数据。
if !strings.Contains(js, `span.className = "att-err"`) {
t.Error("找不到 .att-err 的 DOM 构造 —— 失败占位应改用 createElement/textContent")
}
// ③ 必须有捕获阶段的 error 委托(缺了它,图片失败时用户看到破图无提示)。
//
// 注意:error 事件不冒泡,`addEventListener("error", fn)` 不带 true
// 是**收不到** img 加载失败的 —— 这条注释是为了让改的人别再踩。
if !regexp.MustCompile(`addEventListener\(\s*"error"`).MatchString(js) {
t.Error("缺少 error 事件监听 —— 图片加载失败时不会显示占位")
}
}
// TestDashboardNoEscapedQuoteInHTMLAttributes 是**通用**的同类缺陷扫描。
//
// 它不针对某一行,而是扫整个 dashboard.js:凡是形如 `="...\"..."` 的
// 拼接,都会在浏览器里被提前闭合。
//
// 这条判据的价值在于:下次别人再写内联事件(onclick/onerror/onload…)
// 并试图用 \" 转义时,这里会红,而不是等到生产上出现一行诡异的可见文本。
func TestDashboardNoEscapedQuoteInHTMLAttributes(t *testing.T) {
js := stripJSComments(dashboardJS(t))
// 找 `="` 之后到下一个 `"` 之间出现反斜杠转义引号的片段。
// 注意要排除 JS 字符串里的普通转义(如 "a\"b" 作为纯数据),
// 所以只匹配**紧跟在 HTML 属性名后面**的形态:空白 + 名字 + = "
bad := regexp.MustCompile(`[a-zA-Z-]+="[^"]*\\"`)
if m := bad.FindString(js); m != "" {
t.Errorf("★ 发现 HTML 属性内用反斜杠转义引号(浏览器会提前闭合属性):\n %s\n"+
"HTML 属性值里没有转义序列 —— 需要嵌套引号时改用单引号包属性值,"+
"或改用 DOM API / 事件委托。", m)
}
}