Files
Cyrene_v2/frontend/web/src/admin/modules/channels/channelConnectNotice.test.ts
T
AskaEth b4d55f412e
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(channel): 连接动作不再乐观 —— 适配器自报连接态三态 + 面板如实呈现
根因:adminChannelOpen 在 ch.Connect 返回 nil 后硬编码 connected=true。而 Connect 返回 nil 只说明「这次调用没有致命失败」—— ws+client 首连失败刻意非致命(OneBot 实现可能比 Cyrene 后启动),ws+server 则只需监听就绪,两种情况 IsConnected() 都是 false。前端又只按「请求成功」写死「已连接」,于是管理面把「后端受理了」说成「渠道连上了」。

改法:三态口径落在适配器自己身上(connected/pending/ready,internal/channel/adapter 的纯函数 ConnectStateOf 判定 + ConnectSignals 结构化入参,避免两个 bool 接反),qq_obv11 实现 ConnectOutcome 并记住脱敏后的失败原因;网关经类型断言探测(与 accountStatusAware/rescanAware 同套路,核心 sdk.Channel 契约刻意不扩展),只做映射不猜 transport/mode。

线上投影:open/close 响应与 GET /admin/channels 的平台行/账号行各新增 pending/ready/reason 三个可选键(零值即整键缺席,同 NeedsRescan/LastError 口径);既有四键逐字不变,未实现该状态面的平台输出逐字节不变。close 报真实 IsConnected(),断开也不乐观。

凭据边界:reason 三层脱敏(形状级 → 已知明文替换 → 剥 access_token= 查询参数),日志同样只用脱敏串。

验收:主会话亲自复跑 go build/vet 全绿、全树 go test 无 FAIL、目标三包 test 全绿、前端 tsc --noEmit 干净、esbuild 逻辑测试 34+46 checks 全过;主会话亲自变异摘掉第 3 层脱敏实现,发现原两条脱敏用例其实钉不住它(值恰好等于已知明文/被形状级顺带吃掉)⇒ 补一条钉住它独有窗口的用例(短值+紧邻空白),变异下必红、还原后绿。
2026-10-13 19:38:56 +08:00

214 lines
9.7 KiB
TypeScript
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.
// 「点了连接之后,面板到底说了什么」的真实断言(2026-10-07 UI 撒谎事故的回归测试)。
//
// 为什么必须存在:旧代码只按「请求成功」判断 —— `adminChannelOpen` 一 resolve 就弹
// 「已连接」,而 ws+client 的首连失败**刻意非致命**(OneBot 实现可能比 Cyrene 后启动),
// 于是「后端受理了」被说成了「渠道连上了」。这里不看组件、不靠记忆:既直接驱动
// `createChannelActions`(stub 掉 globalThis.fetch,读它真正写进 notice 的那句话),
// 也直接驱动卡片用的纯函数 `channelStatus`。
//
// 跑法(与 channelActions.test.ts 同款 esbuild + node,react-dom 的 CJS 需要 require):
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/admin/modules/channels/channelConnectNotice.test.ts \
// --bundle --platform=node --format=cjs \
// --outfile=/tmp/cyrene-channel-connect-notice.test.cjs \
// && node /tmp/cyrene-channel-connect-notice.test.cjs
//
// `tsc --noEmit`(npm run build 的一部分)会对本文件做类型检查,因此它只从项目内
// 导入、不 import node:*。
import type { AdminChannelRecord } from "../../../lib/types";
import { channelConnectStateOf, channelStatus } from "./channelStatus";
import { connectDisconnectNotice, createChannelActions } from "./useChannelActions";
let checks = 0;
function ok(cond: boolean, what: string): void {
checks += 1;
if (!cond) throw new Error(`FAILED: ${what}`);
}
function notOk(cond: boolean, what: string): void {
ok(!cond, what);
}
function eq(got: unknown, want: unknown, what: string): void {
checks += 1;
if (got !== want) throw new Error(`FAILED: ${what}(got ${JSON.stringify(got)}, want ${JSON.stringify(want)})`);
}
const REASON = "qq-obv11: dial onebot ws ws://localhost:10301/: connection refused";
// ── ① 纯函数:提示文案只看**响应**,不看请求是否成功 ─────────────────────────────
{
// 本次事故的正题:请求 200 + connected:false + pending:true ⇒ 绝不是「已连接」。
const pending = connectDisconnectNotice(true, "qq-obv11", {
name: "qq-obv11",
connected: false,
pending: true,
reason: REASON,
});
notOk(pending.includes("已连接"), `重试中不许说「已连接」,got ${JSON.stringify(pending)}`);
ok(pending.includes("正在连接"), `重试中必须说「正在连接」,got ${JSON.stringify(pending)}`);
ok(pending.includes(REASON), `重试中必须带上原因,got ${JSON.stringify(pending)}`);
// 连上了才是「已连接」。
eq(
connectDisconnectNotice(true, "qq-obv11:9616b27a", { name: "qq-obv11", connected: true }),
"已连接 qq-obv11:9616b27a",
"真连上才说「已连接」(并回显实际驱动的适配器)",
);
// server 已就绪、等待对端接入:既不是「已连接」,也不是一句静态的「未连接」。
const ready = connectDisconnectNotice(true, "qq-obv11", { name: "qq-obv11", connected: false, ready: true });
notOk(ready.includes("已连接"), `就绪等接入不许说「已连接」,got ${JSON.stringify(ready)}`);
ok(ready.includes("已就绪"), `就绪等接入必须说清楚,got ${JSON.stringify(ready)}`);
// 后端没有这条状态面(两个键都缺):如实说「尚未连上」,绝不乐观。
const bare = connectDisconnectNotice(true, "kook", { name: "kook", connected: false });
notOk(bare.includes("已连接"), `连不上不许说「已连接」,got ${JSON.stringify(bare)}`);
ok(bare.includes("尚未连上"), `必须如实说尚未连上,got ${JSON.stringify(bare)}`);
// 断开同样不乐观:后端报连接仍在,就不能说「已断开」。
eq(
connectDisconnectNotice(false, "kook", { name: "kook", connected: false }),
"已断开 kook",
"真断开才说「已断开」",
);
const stuck = connectDisconnectNotice(false, "kook", { name: "kook", connected: true });
notOk(stuck.includes("已断开"), `仍未断开不许说「已断开」,got ${JSON.stringify(stuck)}`);
}
// ── ② 卡片状态:三态在**真实渲染逻辑**里与「未连接」分开 ────────────────────────
{
const pendingRecord: AdminChannelRecord = {
name: "qq-obv11",
connected: false,
pending: true,
reason: REASON,
};
const pending = channelStatus([pendingRecord], null, pendingRecord);
eq(pending.kind, "connecting", "重试中的卡片是 connecting,不是 disconnected");
eq(pending.label, "连接中", "重试中的卡片写「连接中」");
eq(pending.tone, "warn", "重试中用警示色而不是错误色(它不是故障)");
ok(pending.hint !== null && pending.hint.includes(REASON), "重试中的卡片带上原因");
notOk(pending.label.includes("未连接"), "重试中不许画成「未连接」");
const readyRecord: AdminChannelRecord = { name: "qq-obv11", connected: false, ready: true };
const ready = channelStatus([readyRecord], null, readyRecord);
eq(ready.kind, "ready", "就绪等接入的卡片是 ready");
eq(ready.label, "等待接入", "就绪等接入的卡片写「等待接入」");
eq(ready.tone, "neutral", "就绪等接入不用错误色");
// 回归:后端没这条状态面的平台逐字不变。
const plain: AdminChannelRecord = { name: "kook", connected: false };
const disconnected = channelStatus([plain], null, plain);
eq(disconnected.kind, "disconnected", "没有状态面的平台仍是 disconnected");
eq(disconnected.label, "未连接", "没有状态面的平台仍写「未连接」");
eq(disconnected.tone, "err", "没有状态面的平台仍用错误色");
// 真连上优先:pending 与 connected 同时给出时不许被降级成「连接中」。
const both: AdminChannelRecord = { name: "qq-obv11", connected: true, pending: true };
eq(channelStatus([both], null, both).kind, "connected", "connected=true 优先于 pending");
eq(channelConnectStateOf({}), null, "两个键都缺省 = 没有这条状态面");
eq(channelConnectStateOf({ pending: true }), "pending", "pending 优先");
eq(channelConnectStateOf({ ready: true }), "ready", "ready 单独给出也认");
}
// ── ③ 真实请求:createChannelActions 写进 notice 的那句话 ───────────────────────
// 与前两段不同,这里驱动的是面板按钮真正调用的那一层,并用 stub 的 fetch 回一个
// 「请求成功但没连上」的响应 —— 旧代码在这条路径上会说「已连接」。
interface Captured {
url: string;
method: string;
}
const captured: Captured[] = [];
// 每次请求各取一个响应:三个动作在同一个 tick 里发出,若共用一个可变变量,
// 先发的两次会读到第三次的响应(那样断言的就是错的东西)。
const payloadQueue: unknown[] = [];
function installStubs(): void {
const store = new Map<string, string>();
Object.defineProperty(globalThis, "localStorage", {
configurable: true,
value: {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
removeItem: (k: string) => void store.delete(k),
},
});
Object.defineProperty(globalThis, "fetch", {
configurable: true,
value: (url: string, init?: RequestInit) => {
captured.push({ url: String(url), method: String(init?.method ?? "GET") });
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve({ data: payloadQueue.shift() ?? {} }),
} as unknown as Response);
},
});
}
installStubs();
const notices: string[] = [];
const errors: string[] = [];
let reloads = 0;
const actions = createChannelActions({
log: () => {},
setBusy: () => {},
setError: (e) => {
if (e != null) errors.push(e);
},
setNotice: (n) => notices.push(n ?? ""),
reload: () => {
reloads += 1;
},
});
// 场景 A:后端回「请求成功但仍在重试」。
payloadQueue.push({
name: "qq-obv11",
resolved: "qq-obv11:9616b27a",
accountId: "9616b27a",
connected: false,
pending: true,
reason: REASON,
});
actions.toggle("qq-obv11", true, "9616b27a");
// 场景 B:后端回「真连上」。
payloadQueue.push({ name: "qq-obv11", resolved: "qq-obv11:9616b27a", connected: true });
actions.toggle("qq-obv11", true, "9616b27a");
// 场景 C:断开成功。
payloadQueue.push({ name: "qq-obv11", resolved: "qq-obv11:9616b27a", connected: false });
actions.toggle("qq-obv11", false, "9616b27a");
setTimeout(() => {
const pendingNotice = notices.find((n) => n.includes("正在连接"));
ok(pendingNotice !== undefined, `重试中必须提示「正在连接」,got ${JSON.stringify(notices)}`);
ok(
!notices[0]?.includes("已连接"),
`第一个动作(仍在重试)绝不能说「已连接」,got ${JSON.stringify(notices[0])}`,
);
ok(notices[0]?.includes(REASON) === true, `重试提示必须带上原因,got ${JSON.stringify(notices[0])}`);
ok(
notices.some((n) => n.includes("已连接 qq-obv11:9616b27a")),
`真连上时必须回显实际驱动的适配器,got ${JSON.stringify(notices)}`,
);
ok(
notices.some((n) => n.includes("已断开 qq-obv11:9616b27a")),
`断开成功要说「已断开」,got ${JSON.stringify(notices)}`,
);
eq(errors.length, 0, "这些路径都不该产生错误提示");
eq(reloads, 3, "每次动作后都刷新列表(卡片状态要靠它才不再撒谎)");
eq(captured[0].url, "/api/v1/admin/channels/open", "连接仍打到既有路径");
eq(captured[2].url, "/api/v1/admin/channels/close", "断开仍打到既有路径");
console.log(`channel connect notice test: ${checks} checks passed`);
}, 20);