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 层脱敏实现,发现原两条脱敏用例其实钉不住它(值恰好等于已知明文/被形状级顺带吃掉)⇒ 补一条钉住它独有窗口的用例(短值+紧邻空白),变异下必红、还原后绿。
This commit is contained in:
1 parent
d81635672e
commit
b4d55f412e
23 files changed
+1440
-27
No files matched your search
@@ -0,0 +1,213 @@
|
||||
// 「点了连接之后,面板到底说了什么」的真实断言(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);
|
||||
Reference in new issue
Block a user