Files
HomeAgent/internal/plugins/webui/dashboard_js_scope_test.go
pi 7e4f1058ea chore: 移出本地工具配置 .pi-lens.json
它是 pi-lens 的**仓库级 ignore 配置**,随每个人的工具版本与偏好变化,
对 CI 和其他贡献者毫无意义,还会成为 merge 噪音。
⇒ 移出仓库并加进 .gitignore,各人在本机放自己的一份。

★ 但它注释里的**根因分析要留在仓库**(配置外置、结论内留)——
runbook §6.1/6.2 已写得更全(runner 不按目录语言选+
failed-first 让误报永不自愈),故补 §6.3 说明「为什么仓库里没这个文件」,
原§6.3/6.4 顺延为 §6.4/6.5。

这个上游缺陷不知道哪年修:修之前每个新 clone 的人都会再踩一遍,
所以结论必须内留而配置必须外置。
2026-10-05 13:28:01 +08:00

306 lines
13 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 (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// dashboard.js 的**作用域**判据:所有被跨函数调用的 render* 必须是顶层声明。
//
// ## 要判的是什么(2026-10-05 生产实测白屏根因)
//
// Uncaught (in promise) ReferenceError: renderChatUsage is not defined
// at starmapFetchBlock (dashboard.js:3830) ← 顶层函数
// at async Promise.all (index 2)
// at async renderAll (dashboard.js:3878)
//
// renderChatUsage 定义在 dashboard.js:4475,**缩进是一个 tab**,
// 位置在 connectSSE()(4342 行)内部 ⇒ 它只在 connectSSE 执行期间处于作用域内。
//
// 而 starmapFetchBlock 是**顶层函数**,在 renderAll 里与其它 fetch 并发跑,
// 那时 connectSSE 早已返回 ⇒ renderChatUsage 是 undefined ⇒ ReferenceError。
//
// ★ 为什么这会导致「大量页面白屏」而不是「少一个数字」:
//
// renderAll 里 `await Promise.all(needs.map(starmapFetchBlock))`
// **没有 try/catch**,而 fetch 列表里 overview/plugins/kernel 三个页签
// 都含 "kernel" ⇒ 任何一页触发它,Promise.all 整体 reject,
// **后面的 spec.render.forEach 根本不执行** ⇒ 整页空白。
//
// renderAll 明明给 render 步骤写了 `typeof window[fn] === "function"` 守卫、
// 也给星图写了 try/catch,唯独漏了 fetch 这一步 —— 而它跑在 render 之前。
//
// ★ 为什么这类 bug 能活下来:
// - Go 侧测试只验 dashboard.html/js/css 的**组装**(TestDashboardAssetsSplit),
// 不验 JS 语义 ⇒ 编译、vet、单测全绿。
// - 它只在**运行期、且 state.kernel.usage 存在**(即 /kernel 返回了用量)时触发。
// 新装实例 usage 为 0/null 时不炸 ⇒ 装完看着好好的,跑几天后才白屏。
//
// 判据做法:用 node 在沙箱里把 dashboard.js 跑一遍,抓未捕获异常。
// node 不在时 t.Skip(CI 里 GO 环境可能没有 node)。
// dashboardJSSource 取出待测脚本文本(测试内复用以免重复读 embed)。
func dashboardJSSource(t *testing.T) string {
t.Helper()
b, err := dashboardFS.ReadFile("dashboard.js")
if err != nil {
t.Fatalf("读 dashboard.js: %v", err)
}
return string(b)
}
// TestDashboardJS_HasNoReferenceErrorInStrictMode 用 node 跑真脚本抓 ReferenceError。
//
// ★ 为什么必须真跑而不是 grep「函数名出现过几次」:
//
// 文本上 renderChatUsage 出现了 2 次(定义 + 调用),grep 查不出作用域问题。
// 作用域只能在真执行时暴露 —— 这是本仓反复记的「测真实行为而非形态」。
func TestDashboardJS_HasNoReferenceErrorInStrictMode(t *testing.T) {
if _, err := exec.LookPath("node"); err != nil {
t.Skip("无 node,跳过 JS 语义判据")
}
js := dashboardJSSource(t)
// 触发 kernel 分支里的 renderChatUsage 调用:造一个带 usage 的 /kernel 响应,
// 然后调 starmapFetchBlock("kernel")。
//
// ★★ 桩必须建在 dashboard.js **之前**(否则整个脚本不执行,判据变成「测不到」):
// ① 文件顶部有立即执行的 IIFE(initAppearance / 主题应用),
// 它们访问 localStorage / document.body / window.innerWidth / matchMedia。
// ② 文件末尾有 setInterval(renderAll, 15000) 与 setInterval(sync…, 30000),
// **不接管 setInterval 的话 node 永不退出** —— 实测表现为
// exit 0 且 stdout 全空,看起来像「脚本没报错」,实则根本没跑到断言。
// 这条踩坑本身就是判据设计的坑:静默 hang 会被误读成通过。
harness := `
const __caught = [];
process.on('uncaughtException', (e) => { __caught.push(String(e)); });
process.on('unhandledRejection', (e) => { __caught.push('Rejection: ' + String(e)); });
const __store = {};
globalThis.localStorage = {
getItem: (k) => (k in __store ? __store[k] : null),
setItem: (k, v) => { __store[k] = String(v); },
removeItem: (k) => { delete __store[k]; },
};
globalThis.matchMedia = () => ({ matches: false, addListener(){}, removeListener(){}, addEventListener(){}, removeEventListener(){} });
globalThis.innerWidth = 1400;
globalThis.innerHeight = 900;
globalThis.addEventListener = () => {};
globalThis.requestAnimationFrame = (f) => setTimeout(f, 16);
globalThis.cancelAnimationFrame = () => {};
// ★ 接管定时器:否则永不退出(见上)。
globalThis.setInterval = () => 0;
globalThis.clearInterval = () => {};
const __mkEl = () => ({
style: {}, dataset: {}, children: [],
classList: { add(){}, remove(){}, toggle(){}, contains(){ return false; } },
setAttribute(){}, getAttribute(){ return null; },
appendChild(c){ this.children.push(c); return c; },
removeChild(){}, insertAdjacentHTML(){}, remove(){}, addEventListener(){},
removeEventListener(){}, querySelector(){ return null; }, querySelectorAll(){ return []; },
getBoundingClientRect(){ return { width: 0, height: 0, left: 0, top: 0 }; },
focus(){}, click(){}, closest(){ return null; },
});
globalThis.document = {
documentElement: { setAttribute(){}, classList:{ add(){}, remove(){}, toggle(){} }, style:{ setProperty(){} } },
head: __mkEl(), body: __mkEl(),
createElement: __mkEl, createTextNode: (t) => ({ textContent: t }),
// ★ getElementById 必须返回**可用元素**而不是 null:
// dashboard.js 末尾有顶层调用 renderConfigDisabled()(7129 行),
// 它对 getElementById 的结果写 .innerHTML。返回 null ⇒ TypeError
// ⇒ node 在跑到断言之前就退出,**stdout 全空、exit 0**,
// 看起来像「没报错」,实则一条断言都没执行 —— 静默假绿。
getElementById: () => __mkEl(),
querySelector: () => __mkEl(), querySelectorAll: () => [],
getElementsByClassName: () => [], getElementsByTagName: () => [],
addEventListener(){}, removeEventListener(){}, cookie: '',
};
globalThis.window = globalThis;
globalThis.__ = (zh) => zh;
globalThis.navigator = { language: 'zh-CN', userAgent: 'node', clipboard: { writeText: async () => {} } };
globalThis.location = { href: 'http://x/', origin: 'http://x', protocol: 'http:', hostname: 'x', reload(){}, assign(){} };
globalThis.history = { replaceState(){}, pushState(){} };
globalThis.EventSource = class { constructor(){} addEventListener(){} close(){} };
` + js + `
// ① 让 api() 直接返回带 usage 的 kernel,避开网络。
// ★ content-type 必须是 application/json:api()(dashboard.js:414)先看
// r.headers.get("content-type"),含 "json" 才走 r.json();
// 否则走 r.text() ⇒ state.kernel 变成字符串 ⇒ kernel 分支的
// "if (state.kernel && state.kernel.usage)" 为假 ⇒ 压根走不到那一行,
// 判据变成「测不到」(实测 state.kernel = "")。
globalThis.fetch = async () => ({
ok: true, status: 200,
json: async () => ({ usage: { total: 42, prompt: 10, completion: 32 } }),
text: async () => '',
headers: { get: (k) => (String(k).toLowerCase() === 'content-type' ? 'application/json' : null) },
});
(async () => {
try {
await starmapFetchBlock('kernel');
} catch (e) {
console.log('THROWN:' + String((e && e.stack) ? e.stack.split('\\n')[0] : e));
process.exitCode = 3;
return;
}
await new Promise((r) => setTimeout(r, 60));
if (__caught.length) {
console.log('CAUGHT:' + JSON.stringify(__caught));
process.exitCode = 3;
} else {
console.log('OK');
}
})();
`
// ★ 必须写临时文件而不是 node -e:dashboard.js 有 500KB+,
// 内联进命令行会撞 ARG_MAX(实测 "argument list too long")。
tmp := filepath.Join(t.TempDir(), "harness.js")
if err := os.WriteFile(tmp, []byte(harness), 0o644); err != nil {
t.Fatal(err)
}
out, err := exec.Command("node", tmp).CombinedOutput()
o := string(out)
t.Logf("node 输出: %s", strings.TrimSpace(o))
// ★ 任何形式的运行期异常都算失败,包括 harness 自己报出的 THROWN:。
//
// 先前这里只认 "CAUGHT:",而 harness 是用 try/catch 包住
// starmapFetchBlock 并打印 "THROWN:" 的 ⇒ 真实缺陷被判成
// 「非本次目标」而 Skip,**又是一次假绿**。
// 判据自己的报告格式不能成为它的免罪符。
if strings.Contains(o, "THROWN:") || strings.Contains(o, "CAUGHT:") {
t.Fatalf("dashboard.js 运行期抛错(页面会白屏):\n%s", strings.TrimSpace(o))
}
if err != nil {
if strings.Contains(o, "OK") {
return
}
t.Fatalf("node 执行失败(判据自身的问题,不能 Skip ——\n"+
"Skip 会把「测不到」伪装成「通过」):%v\n%s", err, strings.TrimSpace(o))
}
if !strings.Contains(o, "OK") {
t.Fatalf("node 跑完但没到断言点(又是静默假绿):\n%s", strings.TrimSpace(o))
}
}
// TestDashboardJS_RenderHelpersAreTopLevel 静态钉死作用域:
// 凡出现在 TABS[].render 或被 starmapFetchBlock 调用的 render*,缩进必须是 0。
func TestDashboardJS_RenderHelpersAreTopLevel(t *testing.T) {
js := dashboardJSSource(t)
lines := strings.Split(js, "\n")
// 找出所有形如 `\t*function NAME(` 的声明,记录缩进深度。
depth := map[string]int{}
for _, l := range lines {
t2 := strings.TrimLeft(l, "\t")
if !strings.HasPrefix(t2, "function ") && !strings.HasPrefix(t2, "async function ") {
continue
}
rest := t2[strings.Index(t2, "function ")+len("function "):]
name := strings.TrimSpace(strings.SplitN(rest, "(", 2)[0])
name = strings.TrimPrefix(name, "async ")
depth[name] = len(l) - len(t2) // tab 数
}
// 这些必须顶层:TABS 里声明的 render 名 + starmapFetchBlock 里直接调用的。
mustTop := map[string]string{
"renderChatUsage": "starmapFetchBlock 的 kernel 分支调用它(dashboard.js:3830)",
"renderOverview": "TABS.overview.render",
"renderPlugins": "TABS.plugins.render",
"renderKernel": "TABS.kernel.render",
"renderChat": "TABS.chat.render",
"renderAdapters": "TABS.adapters.render",
}
for name, why := range mustTop {
d, ok := depth[name]
if !ok {
t.Errorf("%s 未找到声明(%s)", name, why)
continue
}
if d != 0 {
// 报告行号
line := 0
for i, l := range lines {
t2 := strings.TrimLeft(l, "\t")
if strings.HasPrefix(t2, "function "+name+"(") ||
strings.HasPrefix(t2, "async function "+name+"(") {
line = i + 1
break
}
}
t.Errorf("%s 声明在缩进 %d 处(dashboard.js:%d),必须顶层(缩进 0)——"+
"否则顶层函数调用它时是 undefined:%s", name, d, line, why)
}
}
}
// TestRenderAll_GuardsEveryStep 钉住 renderAll 的每一步都有兜底。
//
// ★ 这是白屏能扩大的**结构性原因**:fetch 那一步裸奔,
//
// 一个 ReferenceError 就让整页什么都不渲染。
func TestRenderAll_GuardsEveryStep(t *testing.T) {
js := dashboardJSSource(t)
start := strings.Index(js, "async function renderAll(")
if start == -1 {
t.Fatal("找不到 renderAll")
}
// 取到下一个顶层 "\n}\n" 为止。
end := strings.Index(js[start:], "\n}\n")
if end == -1 {
t.Fatal("renderAll 提取失败")
}
body := js[start : start+end]
// Promise.all 那一步必须有容错。
//
// ★ 两种等价写法都算合格,不要把判据钉死成某一种:
//
// ① 逐项 catch:needs.map(n => starmapFetchBlock(n).catch(...))
// ② 整体 try/catch:try { await Promise.all(...) } catch {}
//
// 先前这条判据只认 ②(找字面 "try {"),而修法用的是 ① ⇒
// 修好了判据却变红。**判据必须钉「语义」不钉「写法」**,
// 否则它会惩罚正确的实现。
pa := strings.Index(body, "Promise.all")
if pa == -1 {
t.Fatal("renderAll 里没有 Promise.all,结构变了?")
}
before := body[max(0, pa-400):pa]
after := body[pa:min(len(body), pa+900)]
// ① 逐项 catch:在 map 回调里对每个 promise 接 .catch(。
// 窗口要够大:真实写法里 .catch 距 Promise.all 约 200+ 字符
// (中间隔着 needs.map(function (n) { … })),窗口太小会漏判。
hasPerItemCatch := strings.Contains(after, ").catch(") ||
strings.Contains(after, ").catch(function") ||
strings.Contains(after, ".catch((e)") ||
strings.Contains(after, ".catch(function")
// ② 整体 try/catch:Promise.all 前后有 try { … } catch。
hasTryCatch := (strings.Contains(before, "try {") || strings.Contains(before, "try{")) &&
strings.Contains(after, "catch")
if !hasPerItemCatch && !hasTryCatch {
t.Errorf("renderAll 的 Promise.all(fetch 步骤)没有容错 ——" +
"任一 fetch 块抛错会让 Promise.all 整体 reject,**后面的 spec.render 根本不执行**" +
" ⇒ 整页白屏(生产实测:starmapFetchBlock(\"kernel\") 里的 ReferenceError" +
" 连带 overview/plugins/kernel 三页全白)。\n" +
"★ 合格写法二选一:逐项 .catch(...),或整体 try/catch。" +
"参考同函数里 spec.render 已有的 typeof 守卫 —— 那一步一直有兜底," +
"唯独跑在它之前的 fetch 步骤没有。")
}
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
func max(a, b int) int {
if a > b {
return a
}
return b
}