@mrrisega/dsh-remote 0.7.0-beta.2 → 0.7.0-beta.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/clients/dsh-remote/dsh-bridge.mjs +20 -0
- package/clients/dsh-remote/mobile-adapter.mjs +115 -31
- package/clients/dsh-remote/test/device-registration-report.test.mjs +133 -0
- package/clients/dsh-remote/test/mobile-adapter-runtime.test.mjs +74 -0
- package/clients/dsh-remote/test/wechat-runtime.test.mjs +87 -1
- package/clients/dsh-remote/wechat-runtime.mjs +75 -11
- package/dsh-setup.mjs +53 -4
- package/package.json +7 -6
- package/packages/dsh-remote-web/lib/client.js +167 -63
- package/packages/dsh-remote-web/lib/index.js +162 -10
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +20 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +115 -31
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-runtime.mjs +75 -11
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +53 -4
- package/packages/dsh-remote-web/test/settings-entry-placement.test.mjs +185 -0
- package/packages/dsh-remote-web/test/settings-entry.test.mjs +22 -8
- package/packages/dsh-remote-web/test/setup-output.test.mjs +37 -0
- package/packages/dsh-remote-web/test/telemetry.test.mjs +38 -0
- package/packages/dsh-remote-web/test/uninstall-runtime.test.mjs +9 -1
|
@@ -254,8 +254,25 @@ function resolveInstallVersion() {
|
|
|
254
254
|
return JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8")).version || "";
|
|
255
255
|
} catch { return ""; }
|
|
256
256
|
}
|
|
257
|
+
/**
|
|
258
|
+
* 宿主(DeepSeek Harness)版本:**由插件注入**,bridge 自己算不出来。
|
|
259
|
+
*
|
|
260
|
+
* 为什么必须是注入:宿主版本要从"宿主进程的 argv / 安装目录"推出来,而 bridge 是插件
|
|
261
|
+
* **另外拉起的独立进程**(脱离宿主,见 dsh-setup.mjs 的守护),它的 argv 与宿主毫无关系。
|
|
262
|
+
* 插件跑在宿主进程内,能拿到真实版本(见 packages/dsh-remote-web/lib/index.js 的
|
|
263
|
+
* harnessVersion()),所以由它经 DSH_BRIDGE_HARNESS_VERSION 传下来。
|
|
264
|
+
*
|
|
265
|
+
* 与 INSTALL_VERSION 的区别(曾经把这两个数混为一谈,导致版本问题答不出来):
|
|
266
|
+
* install_version = **本插件**的版本(用户装的 dsh-remote-web 是哪个版本)
|
|
267
|
+
* harness_version = **宿主**的版本(用户跑的 DeepSeek Harness 是哪个版本)
|
|
268
|
+
* 只知其一都定位不了"是不是宿主升级导致的兼容性故障"。
|
|
269
|
+
*/
|
|
270
|
+
function resolveHarnessVersion() {
|
|
271
|
+
return String(process.env.DSH_BRIDGE_HARNESS_VERSION || "").trim();
|
|
272
|
+
}
|
|
257
273
|
const INSTALL_SOURCE = resolveInstallSource();
|
|
258
274
|
const INSTALL_VERSION = resolveInstallVersion();
|
|
275
|
+
const HARNESS_VERSION = resolveHarnessVersion();
|
|
259
276
|
|
|
260
277
|
// ---------- 稳定设备身份(.dsh-config.json) ----------
|
|
261
278
|
|
|
@@ -1399,6 +1416,9 @@ async function registerDeviceInAccount(token) {
|
|
|
1399
1416
|
// install_source: npx(安装器注入) | plugin_market(插件拉起/自愈,默认) | unknown(白名单外)
|
|
1400
1417
|
install_source: INSTALL_SOURCE,
|
|
1401
1418
|
install_version: INSTALL_VERSION,
|
|
1419
|
+
// v15 宿主版本(由插件注入;老插件不注入 → 空串 = 未上报)。服务端 devices.harness_version。
|
|
1420
|
+
// 与 install_version 是两个不同的数:前者是"插件版本",这个是"DeepSeek Harness 版本"。
|
|
1421
|
+
harness_version: HARNESS_VERSION,
|
|
1402
1422
|
host_os: process.platform,
|
|
1403
1423
|
host_arch: process.arch
|
|
1404
1424
|
})
|
|
@@ -1028,6 +1028,74 @@ const SCRIPT = `(() => {
|
|
|
1028
1028
|
窄屏还会自动收起左栏(viewportWidth < 1024 → narrowExpanded = false)。
|
|
1029
1029
|
我们插一脚只会把官方推到相反方向(这正是「点开变黑遮罩」那条链的起点)。 */
|
|
1030
1030
|
let fabDone = false;
|
|
1031
|
+
|
|
1032
|
+
/* ===== 📶 网络延迟采样器(2026-10-10 二修,与"某一代菜单 DOM"彻底解耦)=================
|
|
1033
|
+
一修(0.7.0-beta.3)改掉了"亚毫秒样本被丢弃",但业主实测**仍然一直「测速中」**,
|
|
1034
|
+
而同一时段镜像页打了 69 次 /api/agentPresets/list 这类**完全合格**的请求 ——
|
|
1035
|
+
说明真根因不是样本被过滤,而是**采样根本没接上**(或渲染写进了已被替换的旧节点)。
|
|
1036
|
+
|
|
1037
|
+
所以这次不再依赖"建菜单时顺手包装一次 fetch":
|
|
1038
|
+
· 安装独立成 maInstallNetSampler(),幂等、**自愈**(发现 window.fetch 上不是我们的采样器
|
|
1039
|
+
就重新包一层)—— 谁在这之后把 fetch 换掉(我们自己注入的 E2EE shim 会在解锁时再包一次)
|
|
1040
|
+
都能兜住;
|
|
1041
|
+
· 样本挂 window(跨"菜单被 SPA 重建"共享);
|
|
1042
|
+
· 渲染**每次现查实时 DOM**,不再抱着建菜单时的旧引用(旧引用可能已脱离文档,
|
|
1043
|
+
于是样本明明在涨、界面却永远停在初始文案);
|
|
1044
|
+
· 空态**直接说明原因**,下次再出问题不用猜:一眼看出是"没接上"还是"接上了但没请求"。 */
|
|
1045
|
+
const maNetPatch = { ok: false, samples: 0, lastUrl: "", lastAt: 0 };
|
|
1046
|
+
try { window.__dshMaNet = maNetPatch; } catch (eNet0) { /* 忽略 */ }
|
|
1047
|
+
/** 当前这一代菜单注册的渲染器(可能为 null:菜单还没建)。 */
|
|
1048
|
+
let maNetRender = null;
|
|
1049
|
+
const maNoteRtt = (ms, url) => {
|
|
1050
|
+
try {
|
|
1051
|
+
if (typeof ms !== "number" || !isFinite(ms)) return;
|
|
1052
|
+
const r = Math.round(ms);
|
|
1053
|
+
// 只拒绝负数与超时;0(本机/回环的真实往返不足 1ms)照收,展示层再夹到 ≥1ms。
|
|
1054
|
+
if (r < 0 || r > 20000) return;
|
|
1055
|
+
const bag = (window.__dshMaRttSamples || (window.__dshMaRttSamples = []));
|
|
1056
|
+
bag.push(r);
|
|
1057
|
+
while (bag.length > 8) bag.shift();
|
|
1058
|
+
maNetPatch.samples = bag.length;
|
|
1059
|
+
maNetPatch.lastUrl = String(url || "").slice(0, 80);
|
|
1060
|
+
maNetPatch.lastAt = Date.now();
|
|
1061
|
+
if (maNetRender) { try { maNetRender(); } catch (eR) { /* 忽略 */ } }
|
|
1062
|
+
} catch (eN) { /* 忽略 */ }
|
|
1063
|
+
};
|
|
1064
|
+
/** 把采样器挂到 window.fetch 上(**只挂一次**)。返回是否已就绪。 */
|
|
1065
|
+
const maInstallNetSampler = () => {
|
|
1066
|
+
try {
|
|
1067
|
+
if (typeof window.fetch !== "function") return false;
|
|
1068
|
+
/* ⚠️ 幂等判据必须是**稳定标志**,不能拿 window.fetch.__dshMaNetSampler 当判据(2026-10-10 自测抓到):
|
|
1069
|
+
"会话过期探测"那层包装会在本采样器**外面**再包一层(它建菜单时才装,晚于这里),
|
|
1070
|
+
于是最外层 window.fetch 上没有我们的标记 → 若用函数标记当判据,每一拍都会再包一层,
|
|
1071
|
+
秒级泄漏(请求穿过 N 层、同一趟被记 N 个样本)。 */
|
|
1072
|
+
if (maNetPatch.installed === true) { maNetPatch.ok = true; return true; }
|
|
1073
|
+
const orig = window.fetch;
|
|
1074
|
+
const wrapped = function (input, init) {
|
|
1075
|
+
let url = "";
|
|
1076
|
+
try { url = typeof input === "string" ? input : (input && input.url) || ""; } catch (eU) { url = ""; }
|
|
1077
|
+
const t0 = maNowMs();
|
|
1078
|
+
const p = orig.apply(this, arguments);
|
|
1079
|
+
try {
|
|
1080
|
+
const abs = url.slice(0, 8).toLowerCase();
|
|
1081
|
+
const absolute = abs.indexOf("http://") === 0 || abs.indexOf("https://") === 0;
|
|
1082
|
+
const sameOrigin = !absolute || url.indexOf(window.location.origin) === 0;
|
|
1083
|
+
const apiish = url.indexOf("/api/") >= 0 || url.indexOf("/remote/") >= 0
|
|
1084
|
+
|| url.indexOf("_devices") >= 0 || url.indexOf("_quota") >= 0;
|
|
1085
|
+
if (sameOrigin && apiish && p && typeof p.then === "function") {
|
|
1086
|
+
p.then(() => { maNoteRtt(maNowMs() - t0, url); }, () => { /* 网络错误不算延迟 */ });
|
|
1087
|
+
}
|
|
1088
|
+
} catch (eW) { /* 忽略 */ }
|
|
1089
|
+
return p;
|
|
1090
|
+
};
|
|
1091
|
+
wrapped.__dshMaNetSampler = true; // 只作记号便于人造排查(**不作**幂等判据)
|
|
1092
|
+
maNetPatch.installed = true; // 稳定幂等判据
|
|
1093
|
+
window.fetch = wrapped;
|
|
1094
|
+
maNetPatch.ok = true;
|
|
1095
|
+
return true;
|
|
1096
|
+
} catch (eI) { return false; }
|
|
1097
|
+
};
|
|
1098
|
+
|
|
1031
1099
|
const buildRemoteMenu = () => {
|
|
1032
1100
|
/* ===== 统一「远程控制」悬浮按钮:字号 + 加密状态 + 返回设备列表 =====
|
|
1033
1101
|
2026-09-19 用户要求原话:「把字体的菜单和加密小锁的菜单合并成一个小悬浮按钮;点击后可以弹菜单
|
|
@@ -1244,9 +1312,11 @@ const SCRIPT = `(() => {
|
|
|
1244
1312
|
旧的 120/300 分档是照"网页首屏"的体感定的,会把大量正常可用的移动网络误报成"较慢",
|
|
1245
1313
|
反而让用户以为哪里坏了。 */
|
|
1246
1314
|
const RTT_KEEP = 8, RTT_MAX_MS = 20000;
|
|
1247
|
-
|
|
1315
|
+
/* 建菜单时的引用只作**兜底**:正常路径每次渲染都现查实时 DOM(见 liveNetEl/liveRttEl)。 */
|
|
1248
1316
|
const rttEl = fab.querySelector('[data-role="rtt"]');
|
|
1249
1317
|
const netEl = menu.querySelector('[data-role="net"]');
|
|
1318
|
+
/* 样本一律读 window 上那一份(由上面的 maNoteRtt 写入,跨"菜单被重建"共享)。 */
|
|
1319
|
+
const rttSamples = (window.__dshMaRttSamples || (window.__dshMaRttSamples = []));
|
|
1250
1320
|
const rttValue = () => {
|
|
1251
1321
|
if (!rttSamples.length) return null;
|
|
1252
1322
|
const s = rttSamples.slice().sort((a, b) => a - b);
|
|
@@ -1258,37 +1328,45 @@ const SCRIPT = `(() => {
|
|
|
1258
1328
|
const rttGrade = (ms) => (ms <= 500 ? "ok" : ms <= 1000 ? "mid" : ms <= 2000 ? "slow" : "bad");
|
|
1259
1329
|
const rttLabel = (g) => (g === "ok" ? "顺畅" : g === "mid" ? "正常" : g === "slow" ? "偏慢" : "很慢");
|
|
1260
1330
|
const FAB_TITLE_BASE = "远程控制:字号 / 网络延迟 / 加密状态 / 返回设备列表(可拖动移动位置)";
|
|
1331
|
+
/* 🔴 每次渲染都**现查实时 DOM**(2026-10-10 二修)。
|
|
1332
|
+
抱着建菜单时的旧引用是"样本在涨、界面却永远停在初始文案"的经典成因:官方 SPA 换掉 body
|
|
1333
|
+
子树后旧引用脱离文档,更新写进了看不见的节点。现查则永远写用户真正看得见的那一个;
|
|
1334
|
+
查不到才退回旧引用(宽屏/菜单未开时本来就没有可写的节点)。 */
|
|
1335
|
+
const liveNetEl = () => { try { return document.querySelector(".dsh-ma-menu-net") || netEl; } catch (eQ) { return netEl; } };
|
|
1336
|
+
const liveRttEl = () => { try { return fab.querySelector('[data-role="rtt"]') || rttEl; } catch (eQ2) { return rttEl; } };
|
|
1261
1337
|
const renderRtt = () => {
|
|
1262
1338
|
try {
|
|
1263
1339
|
const ms = rttValue();
|
|
1264
1340
|
const g = ms === null ? "" : rttGrade(ms);
|
|
1265
|
-
|
|
1266
|
-
|
|
1341
|
+
// 0ms 是句谎话(不足 1ms 的真实往返会被四舍五入成 0)→ 展示层夹到 ≥1ms。
|
|
1342
|
+
const shown = ms === null ? null : Math.max(1, Math.round(ms));
|
|
1343
|
+
const rEl = liveRttEl();
|
|
1344
|
+
if (rEl) {
|
|
1345
|
+
if (shown === null) { rEl.hidden = true; rEl.textContent = ""; rEl.removeAttribute("data-rtt"); }
|
|
1267
1346
|
else {
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1347
|
+
rEl.hidden = false;
|
|
1348
|
+
rEl.textContent = shown + "ms";
|
|
1349
|
+
rEl.setAttribute("data-rtt", g);
|
|
1271
1350
|
}
|
|
1272
1351
|
}
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
const
|
|
1277
|
-
|
|
1278
|
-
if (
|
|
1352
|
+
const nEl = liveNetEl();
|
|
1353
|
+
if (nEl) {
|
|
1354
|
+
nEl.setAttribute("data-rtt", g || "");
|
|
1355
|
+
const ico = nEl.querySelector(".ico");
|
|
1356
|
+
const txt = nEl.querySelector(".txt");
|
|
1357
|
+
if (ico) ico.textContent = shown === null ? "📶" : (g === "bad" ? "📵" : "📶");
|
|
1358
|
+
if (txt) {
|
|
1359
|
+
/* 空态**说清原因**(2026-10-10):以前只有一句「等一次网络请求后显示」,
|
|
1360
|
+
于是"采样器压根没接上"和"接上了但还没有请求"长得一模一样,业主两次反馈
|
|
1361
|
+
我们都只能猜。现在一眼可分。 */
|
|
1362
|
+
txt.textContent = shown !== null ? (shown + " ms · " + rttLabel(g))
|
|
1363
|
+
: (maNetPatch.ok ? "测速中…(已接上,等一次请求)" : "测速中…(采样器未接上)");
|
|
1364
|
+
}
|
|
1279
1365
|
}
|
|
1280
|
-
try { fab.title =
|
|
1366
|
+
try { fab.title = shown === null ? FAB_TITLE_BASE : ("远程控制:网络延迟 " + shown + "ms(" + rttLabel(g) + ") / 字号 / 加密状态 / 返回设备列表(可拖动)"); } catch (e) { /* 忽略 */ }
|
|
1281
1367
|
} catch (e) { /* 延迟显示失败绝不影响主功能 */ }
|
|
1282
1368
|
};
|
|
1283
|
-
|
|
1284
|
-
const noteRtt = (ms) => {
|
|
1285
|
-
if (typeof ms !== "number" || !isFinite(ms)) return;
|
|
1286
|
-
const r = Math.round(ms);
|
|
1287
|
-
if (r < 1 || r > RTT_MAX_MS) return; // 亚毫秒=同帧缓存命中;>20s=卡住/超时 → 都不是"网络延迟"
|
|
1288
|
-
rttSamples.push(r);
|
|
1289
|
-
while (rttSamples.length > RTT_KEEP) rttSamples.shift();
|
|
1290
|
-
renderRtt();
|
|
1291
|
-
};
|
|
1369
|
+
maNetRender = renderRtt; // 注册给采样器(样品到达时回调它)
|
|
1292
1370
|
renderRtt();
|
|
1293
1371
|
|
|
1294
1372
|
/* ❌ 这里曾有「打开右侧栏」这一段与 maRightPane* 三件套 —— 2026-09-27 根治时整条删除。
|
|
@@ -1437,14 +1515,9 @@ const SCRIPT = `(() => {
|
|
|
1437
1515
|
window.fetch = function (input, init) {
|
|
1438
1516
|
let url = "";
|
|
1439
1517
|
try { url = typeof input === "string" ? input : (input && input.url) || ""; } catch (e5) { url = ""; }
|
|
1440
|
-
/*
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
静态壳/图片/字体不该代表"网络延迟";telemetry 那条 /dsh-remote/telemetry/... 也
|
|
1444
|
-
不在 apiish 里(它既不含 "/api/" 也不含 "/remote/",只有 "-remote/"),
|
|
1445
|
-
所以一次性的遥测上报不会污染样本。
|
|
1446
|
-
⚠️ 失败(reject)/超时(>20s)不进样本 —— 那不是"延迟",是"没通"(noteRtt 里已过滤)。 */
|
|
1447
|
-
const t0 = maNowMs();
|
|
1518
|
+
/* ⚠️ 2026-10-10:这里**不再**做网络延迟采样 —— 采样已独立成 maInstallNetSampler()
|
|
1519
|
+
(与菜单 DOM 解耦、幂等、自愈)。本包装只负责「会话过期探测」这一件事,
|
|
1520
|
+
否则同一趟请求会被量两次。 */
|
|
1448
1521
|
const p = origFetch.call(this, input, init);
|
|
1449
1522
|
try {
|
|
1450
1523
|
const abs = url.slice(0, 8).toLowerCase();
|
|
@@ -1453,7 +1526,7 @@ const SCRIPT = `(() => {
|
|
|
1453
1526
|
if (sameOrigin && apiish && p && typeof p.then === "function") {
|
|
1454
1527
|
p.then((res) => {
|
|
1455
1528
|
try { noteStatus(res && res.status); } catch (e6) { /* 忽略 */ }
|
|
1456
|
-
|
|
1529
|
+
/* 延迟采样已移到 maInstallNetSampler,这里只报会话过期 */
|
|
1457
1530
|
}, () => { /* 网络错误不算会话过期,也不算延迟样本 */ });
|
|
1458
1531
|
}
|
|
1459
1532
|
} catch (e7) { /* 忽略 */ }
|
|
@@ -1497,6 +1570,11 @@ const SCRIPT = `(() => {
|
|
|
1497
1570
|
let done = false;
|
|
1498
1571
|
const boot = () => {
|
|
1499
1572
|
if (!HOSTISH()) return;
|
|
1573
|
+
/* 📶 **先**把延迟采样器挂上(2026-10-10):它必须在"任何请求发生之前"就位。
|
|
1574
|
+
即便 buildRemoteMenu() 因为任何原因没能建出菜单(或抛错),采样器也照挂不误 ——
|
|
1575
|
+
旧实现把包装写在 buildRemoteMenu 内部的一次性分支里,那正是"请求明明在打、
|
|
1576
|
+
界面却永远测速中"的结构性原因。 */
|
|
1577
|
+
try { maInstallNetSampler(); } catch (eSampler0) { /* 忽略 */ }
|
|
1500
1578
|
/* 悬浮按钮/菜单:任何宽度都要有(电脑端访问镜像页时同样需要返回设备列表的出口)。
|
|
1501
1579
|
失败也不影响手机端布局适配 —— 两条链路各自独立。 */
|
|
1502
1580
|
if (!fabDone) { fabDone = true; try { buildRemoteMenu(); } catch (eFabDone) { /* 忽略 */ } }
|
|
@@ -1964,6 +2042,12 @@ const SCRIPT = `(() => {
|
|
|
1964
2042
|
const pump = () => {
|
|
1965
2043
|
if (maTorn) return;
|
|
1966
2044
|
if (bootUseful()) { try { boot(); } catch (eBoot) { /* 幂等重试 */ } }
|
|
2045
|
+
/* 📶 采样器**自愈**(2026-10-10):本函数每拍都跑(MutationObserver/1s 兜底),
|
|
2046
|
+
所以只要 window.fetch 被谁换掉(我们自己注入的 E2EE shim 会在"解锁"时再包一层、
|
|
2047
|
+
官方若在运行期替换 fetch 同理),下一拍就会把采样器重新挂回去 —— 幂等,已挂上时零开销。
|
|
2048
|
+
同时顺手重绘一次:官方 SPA 换掉菜单子树后,数字不必等下一次请求才回来。 */
|
|
2049
|
+
try { maInstallNetSampler(); } catch (eSampler) { /* 忽略 */ }
|
|
2050
|
+
try { if (maNetRender) maNetRender(); } catch (eNetRender) { /* 忽略 */ }
|
|
1967
2051
|
try { ensureDrawerChrome(); } catch (eChrome2) { /* 忽略 */ }
|
|
1968
2052
|
/* 镜像页就绪探测(mirror_load 的量测终点):pump 本来就被 MutationObserver(子树) + 1s 兜底表
|
|
1969
2053
|
驱动,官方 frame/输入框一挂上就会在**这一拍或下一拍**(≤1s)命中 —— 这正是契约要求的
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// 设备登记上报口径回归(2026-10 版本分析需求)。
|
|
2
|
+
//
|
|
3
|
+
// 【为什么必须有】生产反馈只能看到「插件版本」(install_version),从来拿不到
|
|
4
|
+
// 「宿主 DeepSeek Harness 版本」—— 于是「是不是 DSH 升级导致的兼容性故障」这个问题
|
|
5
|
+
// 在后台**答不出来**(数据不能回填,只能从新版开始积累)。
|
|
6
|
+
// 宿主版本由插件在**宿主进程里**问出来(harnessVersion()),经 DSH_BRIDGE_HARNESS_VERSION
|
|
7
|
+
// 传给 bridge;bridge 自己(独立进程)推不出来。本用例走真实 bridge 进程 + 假账号 API,
|
|
8
|
+
// 逐字断言**桥到底往 /api/devices 发了什么**。
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import { spawn } from "node:child_process";
|
|
11
|
+
import http from "node:http";
|
|
12
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
13
|
+
import os from "node:os";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import test from "node:test";
|
|
16
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
import { WebSocketServer } from "ws";
|
|
19
|
+
|
|
20
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
const BRIDGE = path.join(HERE, "..", "dsh-bridge.mjs");
|
|
22
|
+
|
|
23
|
+
test("设备登记:harness_version(宿主版本)与 install_version(插件版本)必须分别如实上报", async () => {
|
|
24
|
+
let seen = null;
|
|
25
|
+
const api = http.createServer((req, res) => {
|
|
26
|
+
if (req.url === "/api/device-login") {
|
|
27
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
28
|
+
res.end(JSON.stringify({ token: "token-1" }));
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
if (req.url === "/api/devices") {
|
|
32
|
+
let raw = "";
|
|
33
|
+
req.on("data", (c) => { raw += c; });
|
|
34
|
+
req.on("end", () => {
|
|
35
|
+
try { seen = JSON.parse(raw); } catch { seen = { parseError: raw }; }
|
|
36
|
+
res.writeHead(201, { "content-type": "application/json" });
|
|
37
|
+
res.end(JSON.stringify({ device: { id: "dev-000000000002" } }));
|
|
38
|
+
});
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
res.writeHead(404, { "content-type": "application/json" });
|
|
42
|
+
res.end(JSON.stringify({ error: "not found" }));
|
|
43
|
+
});
|
|
44
|
+
await new Promise((resolve) => api.listen(0, "127.0.0.1", resolve));
|
|
45
|
+
|
|
46
|
+
const wss = new WebSocketServer({ host: "127.0.0.1", port: 0 });
|
|
47
|
+
await new Promise((resolve) => wss.once("listening", resolve));
|
|
48
|
+
|
|
49
|
+
const tempDir = await mkdtemp(path.join(os.tmpdir(), "dsh-device-report-"));
|
|
50
|
+
const child = spawn(process.execPath, [BRIDGE], {
|
|
51
|
+
env: {
|
|
52
|
+
...process.env,
|
|
53
|
+
DSH_BRIDGE_API: `http://127.0.0.1:${api.address().port}`,
|
|
54
|
+
DSH_BRIDGE_TUNNEL_URL: `ws://127.0.0.1:${wss.address().port}`,
|
|
55
|
+
DSH_BRIDGE_PHONE: "test-account",
|
|
56
|
+
DSH_BRIDGE_PASSWORD: "test-password",
|
|
57
|
+
DSH_BRIDGE_DEVICE_ID: "dev-000000000002",
|
|
58
|
+
DSH_BRIDGE_CONFIG: path.join(tempDir, "config.json"),
|
|
59
|
+
// 插件在宿主进程里探测到的宿主版本 → 经环境变量注入 bridge
|
|
60
|
+
DSH_BRIDGE_HARNESS_VERSION: "0.2.0-rc.2",
|
|
61
|
+
DSH_BRIDGE_INSTALL_VERSION: "0.7.0-beta.2",
|
|
62
|
+
DSH_BRIDGE_INSTALL_SOURCE: "plugin_market",
|
|
63
|
+
},
|
|
64
|
+
stdio: "ignore",
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
for (let i = 0; i < 60 && !seen; i += 1) await delay(100, null, { ref: false });
|
|
69
|
+
assert.ok(seen, "bridge 应在 6 秒内完成设备登记(POST /api/devices)");
|
|
70
|
+
assert.equal(seen.harness_version, "0.2.0-rc.2", "★宿主版本必须进登记请求(这是本次需求的核心)");
|
|
71
|
+
assert.equal(seen.install_version, "0.7.0-beta.2", "插件版本照旧上报,两个数都要有");
|
|
72
|
+
assert.equal(seen.install_source, "plugin_market");
|
|
73
|
+
assert.equal(seen.device_id, "dev-000000000002");
|
|
74
|
+
assert.ok(seen.host_os && seen.host_arch, "平台/架构照旧");
|
|
75
|
+
} finally {
|
|
76
|
+
try { child.kill("SIGKILL"); } catch { /* 已退出 */ }
|
|
77
|
+
await new Promise((r) => wss.close(r));
|
|
78
|
+
await new Promise((r) => api.close(r));
|
|
79
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("设备登记:老插件不注入 DSH_BRIDGE_HARNESS_VERSION 时,字段必须是空串(不是缺字段、更不是猜)", async () => {
|
|
84
|
+
let seen = null;
|
|
85
|
+
const api = http.createServer((req, res) => {
|
|
86
|
+
if (req.url === "/api/device-login") {
|
|
87
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
88
|
+
res.end(JSON.stringify({ token: "token-1" }));
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (req.url === "/api/devices") {
|
|
92
|
+
let raw = "";
|
|
93
|
+
req.on("data", (c) => { raw += c; });
|
|
94
|
+
req.on("end", () => {
|
|
95
|
+
try { seen = JSON.parse(raw); } catch { seen = null; }
|
|
96
|
+
res.writeHead(201, { "content-type": "application/json" });
|
|
97
|
+
res.end(JSON.stringify({ device: { id: "dev-000000000003" } }));
|
|
98
|
+
});
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
res.writeHead(404, { "content-type": "application/json" });
|
|
102
|
+
res.end(JSON.stringify({ error: "not found" }));
|
|
103
|
+
});
|
|
104
|
+
await new Promise((resolve) => api.listen(0, "127.0.0.1", resolve));
|
|
105
|
+
const wss = new WebSocketServer({ host: "127.0.0.1", port: 0 });
|
|
106
|
+
await new Promise((resolve) => wss.once("listening", resolve));
|
|
107
|
+
|
|
108
|
+
const tempDir = await mkdtemp(path.join(os.tmpdir(), "dsh-device-report-"));
|
|
109
|
+
const env = { ...process.env };
|
|
110
|
+
delete env.DSH_BRIDGE_HARNESS_VERSION; // 模拟老插件
|
|
111
|
+
const child = spawn(process.execPath, [BRIDGE], {
|
|
112
|
+
env: {
|
|
113
|
+
...env,
|
|
114
|
+
DSH_BRIDGE_API: `http://127.0.0.1:${api.address().port}`,
|
|
115
|
+
DSH_BRIDGE_TUNNEL_URL: `ws://127.0.0.1:${wss.address().port}`,
|
|
116
|
+
DSH_BRIDGE_PHONE: "test-account",
|
|
117
|
+
DSH_BRIDGE_PASSWORD: "test-password",
|
|
118
|
+
DSH_BRIDGE_DEVICE_ID: "dev-000000000003",
|
|
119
|
+
DSH_BRIDGE_CONFIG: path.join(tempDir, "config.json"),
|
|
120
|
+
},
|
|
121
|
+
stdio: "ignore",
|
|
122
|
+
});
|
|
123
|
+
try {
|
|
124
|
+
for (let i = 0; i < 60 && !seen; i += 1) await delay(100, null, { ref: false });
|
|
125
|
+
assert.ok(seen, "bridge 仍应完成设备登记");
|
|
126
|
+
assert.equal(seen.harness_version, "", "取不到就上报空串(= 未上报),绝不猜一个错的版本");
|
|
127
|
+
} finally {
|
|
128
|
+
try { child.kill("SIGKILL"); } catch { /* 已退出 */ }
|
|
129
|
+
await new Promise((r) => wss.close(r));
|
|
130
|
+
await new Promise((r) => api.close(r));
|
|
131
|
+
await rm(tempDir, { recursive: true, force: true });
|
|
132
|
+
}
|
|
133
|
+
});
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
* (既有用例只测注入纯函数,覆盖不到这里,所以这个 bug 当初没被任何测试拦住)。
|
|
16
16
|
*/
|
|
17
17
|
import assert from "node:assert/strict";
|
|
18
|
+
import { readFileSync } from "node:fs";
|
|
18
19
|
import test from "node:test";
|
|
19
20
|
import vm from "node:vm";
|
|
20
21
|
import { injectMobileAdapter } from "../mobile-adapter.mjs";
|
|
@@ -535,6 +536,79 @@ test("网络延迟分档:500ms 以内=顺畅、1000ms 以内=正常(远程
|
|
|
535
536
|
}
|
|
536
537
|
});
|
|
537
538
|
|
|
539
|
+
test("★ 亚毫秒样本(本机/回环自测)也必须出数 —— 不得永远停在「测速中…」", async () => {
|
|
540
|
+
// 业主 2026-10-09 实测反馈:「小悬浮窗一直提示『测速中,等待下一次网络请求后开始测速』」。
|
|
541
|
+
// 根因:旧实现是 `if (r < 1) return;`(注释写"亚毫秒=同帧缓存命中"),但**本机/回环**访问
|
|
542
|
+
// (桌面端自测、在同一台机器上打开镜像页)一次真实往返往往就是 < 1ms —— 于是**每一个**样本
|
|
543
|
+
// 都被丢掉,界面永远停在空态。面板侧 chip 从来没这个毛病(`v <= 0` + `Math.max(1, …)`),
|
|
544
|
+
// 这条用例把两侧口径钉在一起。
|
|
545
|
+
const env = harness({ width: 390, fetchImpl: async () => ({ status: 200, ok: true }) }); // 零延迟 = 亚毫秒
|
|
546
|
+
runAdapter(env);
|
|
547
|
+
const rtt = findFab(env).querySelector('[data-role="rtt"]');
|
|
548
|
+
assert.equal(rtt.hidden, true, "前提:还没有样本");
|
|
549
|
+
await env.win.fetch("/api/session/list");
|
|
550
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
551
|
+
assert.equal(rtt.hidden, false, "★亚毫秒的真实往返必须记成样本(否则本机自测永远是「测速中…」)");
|
|
552
|
+
assert.equal(String(rtt.textContent), "1ms", "展示层夹到 1ms —— 0ms 是谎话");
|
|
553
|
+
// ⚠️ 不断言菜单文案:本测试的假 DOM 不支持 `.txt` 这类**类选择器**(querySelector 只认属性/标签),
|
|
554
|
+
// 所以菜单里那行永远停在初始文案 —— 那是夹具限制,不是产品行为。
|
|
555
|
+
// 真正要证明的是"样本确实被记下了":直接看采样器自己的计数最硬。
|
|
556
|
+
assert.ok(env.win.__dshMaNet.samples >= 1, `★必须真的采到样本,实际 samples=${env.win.__dshMaNet.samples}`);
|
|
557
|
+
assert.ok(env.win.__dshMaNet.lastUrl.includes("/api/"), `样本应来自合格请求,实际 ${env.win.__dshMaNet.lastUrl}`);
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
test("★ 悬浮窗被官方 SPA 重渲染重建后,已积累的样本仍要显示(样本不跟着某一代闭包走)", async () => {
|
|
561
|
+
// fetch 包装只装一次(window.__dshMaSessionWatch 一次性闸门),而悬浮窗可能被重建。
|
|
562
|
+
// 若样本数组长在 buildRemoteMenu 的闭包里,包装会一直喂"上一代"(节点已不在文档里),
|
|
563
|
+
// 新界面读自己那份空数组 → 又是永远「测速中…」。样本挂在 window 上才不会这样。
|
|
564
|
+
const env = harness({
|
|
565
|
+
width: 390,
|
|
566
|
+
fetchImpl: async () => { await new Promise((r) => setTimeout(r, 5)); return { status: 200, ok: true }; },
|
|
567
|
+
});
|
|
568
|
+
runAdapter(env);
|
|
569
|
+
await env.win.fetch("/api/session/list");
|
|
570
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
571
|
+
assert.equal(findFab(env).querySelector('[data-role="rtt"]').hidden, false, "前提:第一代已有样本");
|
|
572
|
+
|
|
573
|
+
// 模拟 SPA 重渲染:官方把 body 子树整片换掉(我们的悬浮窗跟着消失),适配层再建一代。
|
|
574
|
+
env.body.children = [];
|
|
575
|
+
runAdapter(env);
|
|
576
|
+
const fab2 = findFab(env);
|
|
577
|
+
assert.ok(fab2, "重建后应有新的悬浮按钮");
|
|
578
|
+
assert.equal(fab2.querySelector('[data-role="rtt"]').hidden, false,
|
|
579
|
+
"★重建后的悬浮窗必须立刻显示已积累的样本(旧实现读自己那份空数组 → 永远「测速中…」)");
|
|
580
|
+
});
|
|
581
|
+
|
|
582
|
+
test("★ 采样器由 boot 安装(不依赖菜单)+ 空态说得清原因 + 被顶掉能自愈重挂", async () => {
|
|
583
|
+
// 2026-10-10 二修:业主第二次反馈「镜像页一直测速中」,而同一时段镜像页打了 69 次
|
|
584
|
+
// /api/agentPresets/list 这类**完全合格**的请求 —— 说明真根因是**采样没接上**
|
|
585
|
+
// (旧实现把包装写在 buildRemoteMenu 内部的一次性分支里),而不是样本被过滤。
|
|
586
|
+
const env = harness({
|
|
587
|
+
width: 390,
|
|
588
|
+
fetchImpl: async () => { await new Promise((r) => setTimeout(r, 5)); return { status: 200, ok: true }; },
|
|
589
|
+
});
|
|
590
|
+
runAdapter(env);
|
|
591
|
+
// ① 采样器挂在 window.fetch 上,且与"菜单有没有建出来"无关
|
|
592
|
+
// 注意判据用**稳定标志**:建菜单时那层「会话过期探测」包装会包在采样器外面,
|
|
593
|
+
// 所以最外层 window.fetch 上没有我们的记号(拿函数记号当判据会每拍重包 → 泄漏)。
|
|
594
|
+
assert.equal(env.win.__dshMaNet && env.win.__dshMaNet.installed, true, "boot 阶段就应把采样器挂上(不依赖菜单构建成功)");
|
|
595
|
+
assert.equal(env.win.__dshMaNet.ok, true, "诊断对象应表明「已接上」");
|
|
596
|
+
// ② 空态必须能区分"接上了但还没请求"与"压根没接上"(以前两者长得一样,只能靠猜)。
|
|
597
|
+
// 假 DOM 不支持类选择器 → 文案本身用**源码级**断言锁住,状态用采样器自己的标志断言。
|
|
598
|
+
assert.equal(env.win.__dshMaNet.samples, 0, "还没有请求 → 0 个样本");
|
|
599
|
+
const SRC = readFileSync(new URL("../mobile-adapter.mjs", import.meta.url), "utf8");
|
|
600
|
+
assert.match(SRC, /测速中…(已接上,等一次请求)/, "空态(采样器已就绪)必须有独立文案");
|
|
601
|
+
assert.match(SRC, /测速中…(采样器未接上)/, "空态(采样器未接上)必须是**另一种**文案,一眼可分");
|
|
602
|
+
// ③ 被顶掉后自愈:模拟我们自己注入的 E2EE shim 在"解锁"时把 window.fetch 再包一层
|
|
603
|
+
env.win.fetch = async () => { await new Promise((r) => setTimeout(r, 5)); return { status: 200, ok: true }; };
|
|
604
|
+
env.win.__dshMaNet.installed = false; // 模拟"fetch 被换掉、我们的采样器已不在链上"
|
|
605
|
+
env.body.children = []; // 同"重建"用例:清掉上一代节点,否则 findFab 拿到的还是旧的那个
|
|
606
|
+
runAdapter(env); // 再跑一次 = 重新走 boot 的安装路径
|
|
607
|
+
await env.win.fetch("/api/session/list");
|
|
608
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
609
|
+
assert.notEqual(findFab(env).querySelector('[data-role="rtt"]').hidden, true, "重挂之后仍要能采到样本");
|
|
610
|
+
});
|
|
611
|
+
|
|
538
612
|
test("网络延迟:无样本 / 失败请求绝不显示 0ms(显示「测速中…」是唯一正确的空态)", async () => {
|
|
539
613
|
const env = harness({
|
|
540
614
|
width: 390,
|
|
@@ -1983,6 +1983,77 @@ test("★ 事故锁:用户主动 /use 切走的会话不能被「最近完成
|
|
|
1983
1983
|
}
|
|
1984
1984
|
});
|
|
1985
1985
|
|
|
1986
|
+
test("★ 待补发队列:过期的**不发**(丢弃最早的),且只保留最近几条", async () => {
|
|
1987
|
+
// 业主 2026-10-09 口径:「积压超过一定时限后就丢弃掉最早的数据,只保留最近几条」。
|
|
1988
|
+
// 两条理由:① 迟到几小时的"任务跑完了"只会误导用户;② 攒到十几条会在平台恢复时一次连打,
|
|
1989
|
+
// 实测正是这种突发批量触发 `ret=-2 prepare failed` 被限流 → 补发又失败 → 队列再涨(死循环)。
|
|
1990
|
+
const ilink = await fakeIlink();
|
|
1991
|
+
try {
|
|
1992
|
+
const sub = makeFakeSubscriber();
|
|
1993
|
+
const { rt, relayDir } = await boundRuntime(ilink, { subscriber: sub });
|
|
1994
|
+
const statePath = path.join(relayDir, ".wechat-state.json");
|
|
1995
|
+
const stateOf = () => JSON.parse(fs.readFileSync(statePath, "utf8"));
|
|
1996
|
+
const now = Date.now();
|
|
1997
|
+
const H = 60 * 60 * 1000;
|
|
1998
|
+
|
|
1999
|
+
// 手工造一段积压:2 条很旧(>2h)+ 1 条刚失败(刚刚)
|
|
2000
|
+
rt.channel.writeState({
|
|
2001
|
+
pending_outbox: [
|
|
2002
|
+
{ text: "很久以前的完成通知A", kind: "stopped", at: now - 5 * H },
|
|
2003
|
+
{ text: "很久以前的完成通知B", kind: "stopped", at: now - 3 * H },
|
|
2004
|
+
{ text: "刚刚的完成通知C", kind: "stopped", at: now - 1000 },
|
|
2005
|
+
],
|
|
2006
|
+
});
|
|
2007
|
+
// ① 面板看到的是**有效**条数(过期的不该再显示成"待补发")
|
|
2008
|
+
assert.equal(rt.status().pending_outbox, 1, "status 必须按保质期报有效条数(否则重启后面板显示永远发不出去的旧条目)");
|
|
2009
|
+
|
|
2010
|
+
// ② 用户随便发一条 → 触发补发:只发那条没过期的
|
|
2011
|
+
await rt.handleInbound({ from_user_id: "u", item_list: [{ type: 1, text_item: { text: "/status" } }] });
|
|
2012
|
+
await waitFor(() => (stateOf().pending_outbox || []).length === 0);
|
|
2013
|
+
const resent = allTexts(ilink.state).filter((t) => t.startsWith("(补发)"));
|
|
2014
|
+
assert.equal(resent.length, 1, `★ 只应补发未过期的那一条,实际补发 ${resent.length} 条`);
|
|
2015
|
+
assert.match(resent[0], /刚刚的完成通知C/, "补发的必须是没过期的那条");
|
|
2016
|
+
assert.ok(!resent.some((t) => /很久以前的完成通知/.test(t)), "★ 过期条目绝不能发出去");
|
|
2017
|
+
} finally {
|
|
2018
|
+
await ilink.close();
|
|
2019
|
+
}
|
|
2020
|
+
});
|
|
2021
|
+
|
|
2022
|
+
test("★ 待补发队列:超过上限只留最近几条(丢最早,且如实记日志)", async () => {
|
|
2023
|
+
const ilink = await fakeIlink({ onSendMessage: (_s, reply) => reply({ ret: -2, errmsg: "prepare failed" }) });
|
|
2024
|
+
try {
|
|
2025
|
+
const infos = [];
|
|
2026
|
+
const warns = [];
|
|
2027
|
+
const sub = makeFakeSubscriber();
|
|
2028
|
+
sub.sessionsFixture = [{ sessionId: "session-done", title: "跑完的活", running: false, cwd: "/p" }];
|
|
2029
|
+
sub.summaryFixture = "做完了。";
|
|
2030
|
+
const { rt, relayDir } = await boundRuntime(ilink, { subscriber: sub });
|
|
2031
|
+
rt.logger = { info: (m) => infos.push(String(m)), warn: (m) => warns.push(String(m)), error() {}, debug() {}, addSecret() {} };
|
|
2032
|
+
const statePath = path.join(relayDir, ".wechat-state.json");
|
|
2033
|
+
const stateOf = () => JSON.parse(fs.readFileSync(statePath, "utf8"));
|
|
2034
|
+
|
|
2035
|
+
// 连推 9 条(每一条都发不出去 → 全部入队)。每条正文不同,才能断言"留下的是最近的"。
|
|
2036
|
+
for (let i = 1; i <= 9; i += 1) {
|
|
2037
|
+
sub.summaryFixture = `这是第${i}条结论。`;
|
|
2038
|
+
await rt.notify({ kind: NODE_KINDS.TURN_END, sessionId: "session-done", reason: "completed", at: i });
|
|
2039
|
+
}
|
|
2040
|
+
const queued = stateOf().pending_outbox || [];
|
|
2041
|
+
assert.equal(queued.length, 5, `★ 队列上限 = 最近 5 条,实际 ${queued.length}`);
|
|
2042
|
+
const kept = queued.map((q) => String(q.text)).join("\n");
|
|
2043
|
+
for (const i of [5, 6, 7, 8, 9]) {
|
|
2044
|
+
assert.match(kept, new RegExp(`这是第${i}条结论`), `最近的第 ${i} 条必须保留`);
|
|
2045
|
+
}
|
|
2046
|
+
for (const i of [1, 2, 3, 4]) {
|
|
2047
|
+
assert.ok(!kept.includes(`这是第${i}条结论`), `★ 最早的第 ${i} 条必须被丢弃(只留最近几条)`);
|
|
2048
|
+
}
|
|
2049
|
+
assert.ok(warns.some((m) => /待补发队列丢弃 \d+ 条/.test(m)),
|
|
2050
|
+
`丢数据必须如实记日志(不得静默),实际:${JSON.stringify(warns)}`);
|
|
2051
|
+
} finally {
|
|
2052
|
+
await ilink.close();
|
|
2053
|
+
}
|
|
2054
|
+
});
|
|
2055
|
+
|
|
2056
|
+
|
|
1986
2057
|
test("★ 事故锁:订阅 fault 必须落到日志 + 状态(旧实现只 emit、没人接 → 全静默)", async () => {
|
|
1987
2058
|
// 那次「一条推送都没有、日志里也没有任何报错」的根因就在这类静默里:
|
|
1988
2059
|
// `#emitFault` 只 emit('fault'),而运行时没接这个事件 —— follow 失败/发现失败/流上限
|
|
@@ -1990,9 +2061,10 @@ test("★ 事故锁:订阅 fault 必须落到日志 + 状态(旧实现只 em
|
|
|
1990
2061
|
const ilink = await fakeIlink();
|
|
1991
2062
|
try {
|
|
1992
2063
|
const warns = [];
|
|
2064
|
+
const infos = [];
|
|
1993
2065
|
const sub = makeFakeSubscriber();
|
|
1994
2066
|
const { rt, relayDir } = await boundRuntime(ilink, { subscriber: sub });
|
|
1995
|
-
rt.logger = { info()
|
|
2067
|
+
rt.logger = { info: (m) => infos.push(String(m)), warn: (m) => warns.push(String(m)), error() {}, debug() {}, addSecret() {} };
|
|
1996
2068
|
await rt.startChannel(); // 真实接线:#startSubscriber() → 给注入的订阅器挂 handler
|
|
1997
2069
|
try {
|
|
1998
2070
|
sub.emit("fault", { code: "follow-limit", message: "follow 流已达上限 8", sessionId: "session-x", expected: false });
|
|
@@ -2002,9 +2074,23 @@ test("★ 事故锁:订阅 fault 必须落到日志 + 状态(旧实现只 em
|
|
|
2002
2074
|
assert.equal(st.last_fault && st.last_fault.code, "follow-limit", "非预期的订阅异常要记进状态供面板显示");
|
|
2003
2075
|
|
|
2004
2076
|
// 预期内的(子代理会话必然被拒 / 会话刚被删)只记日志,不该污染故障状态
|
|
2077
|
+
const warnCountBefore = warns.length;
|
|
2005
2078
|
sub.emit("fault", { code: "session/agent-busy", message: "busy", sessionId: "sub-1", expected: true });
|
|
2006
2079
|
const st2 = JSON.parse(fs.readFileSync(path.join(relayDir, ".wechat-state.json"), "utf8"));
|
|
2007
2080
|
assert.equal(st2.last_fault.code, "follow-limit", "expected 的异常不该覆盖真正的故障");
|
|
2081
|
+
// ★2026-10-09:预期内的拒绝**不能写成 warn** —— 每生成一个子代理就会来一条,
|
|
2082
|
+
// 成片的 `订阅异常 session/agent-busy …` 会把真正的故障淹掉(业主现场看到的就是这个)。
|
|
2083
|
+
assert.equal(warns.length, warnCountBefore, `expected 的拒绝不得写 warn,实际:${JSON.stringify(warns)}`);
|
|
2084
|
+
assert.ok(infos.some((m) => m.includes("订阅跳过(预期") && m.includes("session=sub-1")),
|
|
2085
|
+
`expected 的拒绝要有一条 info 留痕(绝不静默),实际:${JSON.stringify(infos)}`);
|
|
2086
|
+
// 同一对 (code, session) 反复来也只报一次:否则子代理一多照样刷屏
|
|
2087
|
+
const infoCountBefore = infos.length;
|
|
2088
|
+
sub.emit("fault", { code: "session/agent-busy", message: "busy", sessionId: "sub-1", expected: true });
|
|
2089
|
+
sub.emit("fault", { code: "session/agent-busy", message: "busy", sessionId: "sub-1", expected: true });
|
|
2090
|
+
assert.equal(infos.length, infoCountBefore, "同一 (code, session) 的预期拒绝只报一次(防刷屏)");
|
|
2091
|
+
// 但**不同**会话仍各自留痕一次(可诊断,不被去重抹掉)
|
|
2092
|
+
sub.emit("fault", { code: "session/agent-busy", message: "busy", sessionId: "sub-2", expected: true });
|
|
2093
|
+
assert.equal(infos.length, infoCountBefore + 1, "不同子代理会话各留一条");
|
|
2008
2094
|
} finally {
|
|
2009
2095
|
await rt.stopChannel();
|
|
2010
2096
|
}
|