Files
HomeAgent/cmd/gui/protocol-align.test.mjs
JianFeeeee a9cbdacc13 feat(gui): 协议对齐 —— 补齐 6 个只读诊断端点 + 人设/反代面板
GUI 原来只用 22 个端点,服务端有 47 个。补齐**只读诊断类**:
agents / network / tracker / config / persona / proxy(+services)。

## 刻意不接 /login 与 /logout

那是 cookie 会话认证流程,而 GUI 走 `X-API-Key` 头(见 `api()`)。
接了不是"对齐",是接错。

## 三处新增

**总览诊断卡片**(renderDiagPanel):Agent 健康、LLM 可达、网络端点数、
文件变更集、数据目录、心跳间隔。全部走 `(x && x.y)` 安全取值 ——
任一端点没取到(老内核无该路由、连接断开)只显示 "-",不抛错。

**人设面板**(renderPersona):实测结构是
`{current_prompt, file_override, initialized}`。我第一版按 map 遍历,
结果只会显示三个字段名 —— 真机验证时才发现。改成展示提示词全文 +
两个状态卡。**只读**,编辑涉及保存/回滚/并发覆盖,与"协议对齐"是两件事。

**反代面板**(renderProxy):base_domain / mode / total / manual +
服务列表(`✓ gateway → 127.0.0.1:9890`)。实测字段是
`{name, host, path, url, target, ok, auth, websocket}`,不是我第一版假设的
`subdomain`。

## 加载策略

只加进 `refreshAll`,**不加** `refreshDataOnly`(后者每 15 秒一轮,
诊断数据不必高频轮询)。7 个新端点实测只 +3ms。

## 判据 protocol-align.test.mjs(10 条,真 Electron)

- 7 个 state 槽都取到真实数据
- 总览出现诊断卡片
- 人设面板**真的显示提示词内容**(不只查元素存在)
- 反代面板**真的列出服务**(查 `→` 出现)

### 判据踩的三个坑

1. **默认假 key 导致 9 项全红** —— webui 对错误凭据返回 200 + 登录页 HTML
   (`looks_like_login_page` 能识别),于是所有取数失败。看起来像
   「代码坏了」,实际只是认证缺失。改为默认从 `config.db` 读真 key。
2. **判据绕过了应用路径** —— 表达式里直接调 `refreshDiagData()`,
   于是把应用里的 `await refreshDiagData()` 注释掉,判据**仍全绿**。
   改为走应用自己的 `refreshAll()`。
3. **变异后 GUI 仍加载旧代码** —— `ensure()` 看到端口有页面就复用,
   注入变异后没重启 GUI ⇒ 又一次假绿。**变异测试必须先杀掉 GUI 进程。**

变异测试(注释掉调用点)⇒ 9 项变红,坐实判据验的是真路径。

## 门禁

- `npm run test-live`:22/22 通过(12 性能+滚动 + 10 协议对齐)
- `npm test`、`make test-gui`:全通过
- `go test ./...`:43 包 ok、0 FAIL
2026-09-28 10:45:54 +08:00

165 lines
6.3 KiB
JavaScript
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.

// 协议对齐判据:在真 Electron 里验证补齐的端点真的取到数据、
// 且三个面板渲染出**可见内容**(不是空壳)。
//
// ## 背景
//
// 2026-09-28 用户要求对齐 WebAPI 协议。核实发现 GUI 只用 22 个端点,
// 服务端有 47 个。补齐的是**只读诊断类**:agents / network / tracker /
// config / persona / proxy(+services)。
//
// ★ 刻意**不接** /login 与 /logout:那是 cookie 会话认证流程,
// 而 GUI 走 `X-API-Key` 头(见 api())。接了反而是错的对齐。
//
// ★ 只加进 refreshAll、**不加** refreshDataOnly:后者每 15 秒一轮,
// 诊断数据不必高频轮询。
//
// ## 为什么必须真浏览器
//
// 判据要验的是「面板里真的有内容」,依赖 DOM 渲染 —— node 里测不了。
//
// 运行:node cmd/gui/protocol-align.test.mjs
import { spawn, spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const PORT = Number(process.env.GUI_CDP_PORT || 9370);
// apiKey 从哪来:环境变量优先,否则读生产 config.db。
//
// ★ 默认**不能**用假 key:webui 对错误凭据返回 200 + 登录页 HTML
// (looks_like_login_page 会识别),于是所有取数都失败 ⇒ 判据全红,
// 看起来像「代码坏了」,实际只是认证缺失。踩过一次。
function resolveApiKey() {
if (process.env.GUI_API_KEY) return process.env.GUI_API_KEY;
const db = "/home/newqqagent/config.db";
if (!existsSync(db)) return "";
try {
const out = spawnSync("sqlite3", [db,
"select value from config_webui where key='api_key';"],
{ encoding: "utf8" });
return (out.stdout || "").trim();
} catch {
return "";
}
}
const API_KEY = resolveApiKey();
if (!API_KEY) {
console.log(" ✗ 拿不到 webui api_key:设 GUI_API_KEY 或确认 /home/newqqagent/config.db 可读");
process.exit(1);
}
let failures = 0;
const check = (n, ok, d) => {
if (ok) console.log(` ✓ ${n}`);
else {
failures++;
console.log(` ✗ ${n}${d ? " — " + d : ""}`);
}
};
async function targets() {
try {
return await (await fetch(`http://127.0.0.1:${PORT}/json`)).json();
} catch {
return [];
}
}
async function ensure() {
const t = await targets();
if (t.some((x) => x.type === "page" && x.url.includes("index.html"))) return;
const bin = join(here, "node_modules/.bin/electron");
if (!existsSync(bin)) throw new Error("electron 未安装,先 cd cmd/gui && npm i");
// ★ setsid:detached 只脱离进程组,脚本退出仍会带走刚起的 GUI
// (症状:[tray] READY 打了,判据却报「找不到 GUI 页面」)。
spawn(
"setsid",
["xvfb-run", "-a", "--server-args=-screen 0 1280x900x24", bin, "--no-sandbox",
`--remote-debugging-port=${PORT}`, "."],
{ cwd: here, detached: true, stdio: "ignore" },
).unref();
for (let i = 0; i < 40; i++) {
await new Promise((r) => setTimeout(r, 1000));
const t2 = await targets();
if (t2.some((x) => x.type === "page" && x.url.includes("index.html"))) return;
}
throw new Error(`GUI 启动超时(${PORT})`);
}
async function ev(expr) {
const tabs = await targets();
const page = tabs.find((t) => t.type === "page" && t.url.includes("index.html"));
if (!page) throw new Error("找不到 GUI 页面");
const sock = new WebSocket(page.webSocketDebuggerUrl);
await new Promise((r) => (sock.onopen = r));
const id = Math.floor(Math.random() * 1e6);
sock.send(JSON.stringify({
id, method: "Runtime.evaluate",
params: { expression: expr, awaitPromise: true, returnByValue: true },
}));
// ★ JSON.parse 必须包 try:CDP 也会发非 JSON 帧,裸 parse 抛在回调里
// 既冒泡不到 await 也等不到 resolve ⇒ 整个判据挂死。
const res = await new Promise((r) => {
sock.onmessage = (e) => {
let m;
try {
m = JSON.parse(e.data);
} catch {
return;
}
if (m && m.id === id) r(m);
};
});
sock.close();
const v = res.result?.result?.value;
if (v === undefined) throw new Error(res.result?.exceptionDetails?.text || "求值失败");
return v;
}
await ensure();
const r = await ev(`(async () => {
const K = ${JSON.stringify(API_KEY)};
state.connections = [{id:'p',name:'p',type:'webui',url:'http://127.0.0.1:8080',apiKey:K}];
state.currentConn = state.connections[0];
// ★ 走应用**自己的**入口 refreshAll(),不直接调 refreshDiagData()。
// 早先直接调 refreshDiagData ⇒ 判据绕过了应用里的调用点 ⇒
// 把「await refreshDiagData()」注释掉,判据仍全绿(实测踩过)。
// 变异测试的意义就在于抓这种「判据没测到真路径」。
await refreshAll();
renderOverview(); renderPersona(); renderProxy();
const txt = (id) => { const e = document.getElementById(id); return e ? e.innerText : ''; };
return {
slots: {
agents: !!(state.agents && Array.isArray(state.agents.agents)),
network: !!(state.network && Array.isArray(state.network.endpoints)),
tracker: !!(state.tracker && typeof state.tracker.changesets === 'number'),
runConfig: !!(state.runConfig && !!state.runConfig.daemon),
persona: !!(state.persona && state.persona.current_prompt),
proxy: !!(state.proxy && typeof state.proxy.total === 'number'),
proxyServices: !!(state.proxyServices && Array.isArray(state.proxyServices.services)),
},
overviewHasDiag: txt('view-overview').includes('诊断') || txt('view-overview').includes('Diagnostic'),
personaText: txt('view-persona').slice(0, 120),
proxyText: txt('view-proxy').replace(/\\n+/g, ' | ').slice(0, 200),
};
})()`);
for (const [k, v] of Object.entries(r.slots)) {
check(`端点取到数据:${k}`, v, "state 槽为空 ⇒ 该端点没接上或没取到");
}
check("总览出现诊断卡片", r.overviewHasDiag);
check(
"人设面板显示提示词",
r.personaText.includes("你是") || r.personaText.length > 40,
`实际文本:${r.personaText.slice(0, 60)}`,
);
check("反代面板列出服务", r.proxyText.includes("→"), `实际文本:${r.proxyText.slice(0, 80)}`);
console.log(failures === 0 ? "\n全部通过" : `\n${failures} 项未通过`);
process.exit(failures === 0 ? 0 : 1);