@mrrisega/dsh-remote 0.7.12-beta.6 → 0.8.1-beta.1
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/apply-pending-plugin.mjs +171 -0
- package/clients/dsh-remote/dingtalk-bot.mjs +34 -31
- package/clients/dsh-remote/feishu-bot.mjs +26 -1
- package/clients/dsh-remote/im-format.mjs +105 -0
- package/clients/dsh-remote/test/dingtalk-bot.test.mjs +79 -0
- package/clients/dsh-remote/test/feishu-bot.test.mjs +75 -2
- package/clients/dsh-remote/test/im-format.test.mjs +60 -0
- package/clients/dsh-remote/test/wecom-bot.test.mjs +67 -89
- package/clients/dsh-remote/wecom-bot.mjs +54 -100
- package/dsh-setup.mjs +1 -1
- package/package.json +2 -2
- package/packages/dsh-remote-web/lib/client.js +25 -1
- package/packages/dsh-remote-web/lib/index.js +130 -4
- package/packages/dsh-remote-web/lib/live-update.js +238 -0
- package/packages/dsh-remote-web/lib/self-update.js +1204 -0
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/apply-pending-plugin.mjs +171 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dingtalk-bot.mjs +283 -63
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/feishu-bot.mjs +127 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/im-commands.mjs +302 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/im-format.mjs +105 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wecom-bot.mjs +138 -102
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +1 -1
- package/packages/dsh-remote-web/test/live-update.test.mjs +114 -0
- package/packages/dsh-remote-web/test/self-update.test.mjs +920 -0
|
@@ -52,7 +52,15 @@ import WebSocket from "ws";
|
|
|
52
52
|
|
|
53
53
|
import { createEventSubscriber, NODE_KINDS } from "./dsh-events.mjs";
|
|
54
54
|
// 通道感知格式化(2026-11-08:企微/钉钉 markdown 不支持表格,发前统一适配;飞书/微信各有分支)
|
|
55
|
-
import {
|
|
55
|
+
import {
|
|
56
|
+
SUMMARY_MAX_CHARS,
|
|
57
|
+
buildTurnNotification,
|
|
58
|
+
extractTurnSummary,
|
|
59
|
+
formatForChannel
|
|
60
|
+
} from "./im-format.mjs";
|
|
61
|
+
// 命令层(2026-11-08):/help /status /stop /ls /use /unbind 在**进模型前**拦下执行;
|
|
62
|
+
// 解析/文案/能力门禁三家共用(与会员升级文案同口径)。
|
|
63
|
+
import { executeImCommand, writeImBotsSession } from "./im-commands.mjs";
|
|
56
64
|
// 会员能力表 = **单一真相源**:直接复用微信通道那套(WECHAT_CAPABILITY_TABLE / limitsFor / grantsCap),
|
|
57
65
|
// 不另造一套。企微/飞书/钉钉将来都套同一套判定(见 research/.../im-channel-membership-design.md)。
|
|
58
66
|
import {
|
|
@@ -716,8 +724,8 @@ export function createWecomBot(options = {}) {
|
|
|
716
724
|
const approvalCardTtlMs = positiveNumber(options.approvalCardTtlMs, APPROVAL_CARD_TTL_MS);
|
|
717
725
|
const onApprovalDecision = typeof options.onApprovalDecision === "function" ? options.onApprovalDecision : null;
|
|
718
726
|
// 会话可选绑定(task-24):配置固定的会话(空 = 跟随活跃会话)
|
|
719
|
-
|
|
720
|
-
|
|
727
|
+
let configuredSessionId = String(options.configuredSessionId || "").trim();
|
|
728
|
+
let configuredSessionTitle = truncateText(options.configuredSessionTitle || "", 60);
|
|
721
729
|
// ── 会员判定(2026-11-07 业主拍板:通知免费、审批免费、互动半阉割)──
|
|
722
730
|
// capsProvider 由 bridge 注入(复用微信通道的 resolveWechatTier:字符串档位或 {plan,caps,limits,rev});
|
|
723
731
|
// 拿不到 / 未注入 → **按 free fail-closed**。
|
|
@@ -1367,8 +1375,87 @@ export function createWecomBot(options = {}) {
|
|
|
1367
1375
|
}
|
|
1368
1376
|
|
|
1369
1377
|
/** 把入站文本转给 DSH 会话(尽力,失败给用户一句人话)。 */
|
|
1378
|
+
/**
|
|
1379
|
+
* 命令层(三家共用 `im-commands.mjs`):命中返回 true(已回复,调用方直接 return)。
|
|
1380
|
+
* 回复走被动回复/主动推送(replyText),不经过模型、不计额度。
|
|
1381
|
+
*/
|
|
1382
|
+
async function handleCommand(frame, info) {
|
|
1383
|
+
const text = String((info && info.text) || "");
|
|
1384
|
+
if (!/^\s*\//.test(text)) return false; // 快路径:非命令不构造 ctx
|
|
1385
|
+
const cmd = await executeImCommand({
|
|
1386
|
+
text,
|
|
1387
|
+
ctx: {
|
|
1388
|
+
caps: capsNow(),
|
|
1389
|
+
tier: tierNow(),
|
|
1390
|
+
appUrl,
|
|
1391
|
+
channelLabel: "企业微信",
|
|
1392
|
+
sessions: async () => {
|
|
1393
|
+
try { await refreshSessions({ force: true }); } catch { /* 忽略 */ }
|
|
1394
|
+
return sessionList.map((x) => ({ sessionId: x.id, title: x.title, updatedAt: x.updatedAt, running: false }));
|
|
1395
|
+
},
|
|
1396
|
+
currentSessionId: () => {
|
|
1397
|
+
const known = boundSessionId || activeSession.id || "";
|
|
1398
|
+
if (known) return known;
|
|
1399
|
+
if (configuredSessionId && canCap("sessions")) return configuredSessionId;
|
|
1400
|
+
if (sessionList.length) return sessionList[0].id;
|
|
1401
|
+
const sub = subscriber;
|
|
1402
|
+
try {
|
|
1403
|
+
if (sub && typeof sub.sessions === "function") {
|
|
1404
|
+
const ids = sub.sessions();
|
|
1405
|
+
if (Array.isArray(ids) && ids.length) return String(ids[0]);
|
|
1406
|
+
}
|
|
1407
|
+
} catch { /* 忽略 */ }
|
|
1408
|
+
return "";
|
|
1409
|
+
},
|
|
1410
|
+
currentSessionTitle: () => titleOfSession(boundSessionId || activeSession.id, activeSession.title || ""),
|
|
1411
|
+
cancelSession: async ({ sessionId }) => {
|
|
1412
|
+
const sub = subscriber;
|
|
1413
|
+
if (!sub || typeof sub.cancelSession !== "function") return { ok: false, message: "订阅器未就绪" };
|
|
1414
|
+
try {
|
|
1415
|
+
const r = await sub.cancelSession({ sessionId });
|
|
1416
|
+
return r && typeof r === "object" ? r : { ok: Boolean(r) };
|
|
1417
|
+
} catch (e) { return { ok: false, message: e && e.message ? e.message : String(e) }; }
|
|
1418
|
+
},
|
|
1419
|
+
switchSession: async ({ sessionId, title }) => {
|
|
1420
|
+
const r = writeImBotsSession(relayDir, "wecom", { sessionId, sessionTitle: title });
|
|
1421
|
+
if (r.ok) {
|
|
1422
|
+
configuredSessionId = sessionId;
|
|
1423
|
+
configuredSessionTitle = truncateText(title || "", 60);
|
|
1424
|
+
writeStatus();
|
|
1425
|
+
}
|
|
1426
|
+
return r;
|
|
1427
|
+
},
|
|
1428
|
+
unbind: async () => {
|
|
1429
|
+
state.boundChatId = "";
|
|
1430
|
+
writeStatus();
|
|
1431
|
+
return { ok: true };
|
|
1432
|
+
},
|
|
1433
|
+
statusSnapshot: async () => ({
|
|
1434
|
+
connected: state.connected === true,
|
|
1435
|
+
authenticated: state.authenticated === true,
|
|
1436
|
+
state: state.connected ? "connected" : "idle",
|
|
1437
|
+
lastError: state.lastError || "",
|
|
1438
|
+
tier: tierNow(),
|
|
1439
|
+
usage: { month: monthKey(), messages: usageThisMonth() },
|
|
1440
|
+
messagesPerMonth: messageLimitNow(),
|
|
1441
|
+
remainingMessages: messageLimitNow() ? Math.max(0, messageLimitNow() - usageThisMonth()) : null,
|
|
1442
|
+
currentSessionId: boundSessionId || activeSession.id || "",
|
|
1443
|
+
currentSessionTitle: titleOfSession(boundSessionId || activeSession.id, activeSession.title || "")
|
|
1444
|
+
})
|
|
1445
|
+
}
|
|
1446
|
+
});
|
|
1447
|
+
if (!cmd || !cmd.handled) return false;
|
|
1448
|
+
replyText(frame, cmd.text);
|
|
1449
|
+
emitter.emit("command", { ...info, command: cmd.command, ok: cmd.ok === true, gated: cmd.gated === true });
|
|
1450
|
+
log.info(`命令 ${cmd.command}${cmd.gated ? "(会员闸拦截)" : ""} 已回复(不转模型)`);
|
|
1451
|
+
return true;
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1370
1454
|
async function dispatchInbound(frame, info) {
|
|
1371
1455
|
const sub = subscriber;
|
|
1456
|
+
// ⓪ 命令层:命中即执行并回复,**绝不进模型**,也不计入额度(/stop、/unbind 永远免费)
|
|
1457
|
+
const cmd = await handleCommand(frame, info);
|
|
1458
|
+
if (cmd) return;
|
|
1372
1459
|
// ① 会员闸(通知/审批免费;派活受能力与月度额度约束,fail-closed)
|
|
1373
1460
|
const gated = await membershipGate(frame, info);
|
|
1374
1461
|
if (gated) return;
|
|
@@ -1971,102 +2058,42 @@ export function createWecomBot(options = {}) {
|
|
|
1971
2058
|
}
|
|
1972
2059
|
|
|
1973
2060
|
/**
|
|
1974
|
-
*
|
|
2061
|
+
* 任务通知(完成/出错/被拦/停止):**一条**消息 = 标题行 + 总结正文。
|
|
1975
2062
|
*
|
|
1976
|
-
*
|
|
1977
|
-
*
|
|
1978
|
-
*
|
|
1979
|
-
* · 距那次入站 < TTL(默认 10 分钟)—— 超时就当另一次对话,走原来的标题通知;
|
|
1980
|
-
* · 事件时间不能早于入站时间(给 5s 时钟/批处理余量)。
|
|
2063
|
+
* 正文优先订阅器 `lastAssistantText({sessionId})`(turn/end 瞬间可能还没落库 → 沿用旧短重试),
|
|
2064
|
+
* 取不到再用事件字段、再取不到降级"仅标题";正文按企业微信通道渲染并限制 1500 字。
|
|
2065
|
+
* **日志只打长度/来源,绝不打印正文**(可能有代码/密钥/隐私)。
|
|
1981
2066
|
*/
|
|
1982
|
-
function
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
2067
|
+
async function sendTurnNotice(node, target) {
|
|
2068
|
+
let summary = { text: "", source: "none" };
|
|
2069
|
+
try {
|
|
2070
|
+
summary = await extractTurnSummary({
|
|
2071
|
+
node,
|
|
2072
|
+
subscriber: answerRelayEnabled ? subscriber : null,
|
|
2073
|
+
sessionId: node.sessionId,
|
|
2074
|
+
maxChars: SUMMARY_MAX_CHARS,
|
|
2075
|
+
retryDelays: answerRelayEnabled ? ANSWER_FETCH_RETRY_DELAYS_MS : null
|
|
2076
|
+
});
|
|
2077
|
+
} catch { summary = { text: "", source: "none" }; }
|
|
2078
|
+
if (stopped) return;
|
|
2079
|
+
const text = buildTurnNotification({
|
|
2080
|
+
node,
|
|
2081
|
+
channel: "wecom",
|
|
2082
|
+
summary: summary.text,
|
|
2083
|
+
sessionTitle: titleOfSession(node.sessionId, ""),
|
|
2084
|
+
maxChars: SUMMARY_MAX_CHARS
|
|
2085
|
+
});
|
|
2086
|
+
if (!text) return;
|
|
2087
|
+
log.info(`任务通知 kind=${node.kind} reason=${node.reason || "?"} summary=${summary.source} len=${Array.from(summary.text).length}`);
|
|
2088
|
+
sendMarkdown(target, text);
|
|
1991
2089
|
}
|
|
1992
2090
|
|
|
1993
2091
|
/**
|
|
1994
|
-
*
|
|
1995
|
-
*
|
|
1996
|
-
*
|
|
1997
|
-
*
|
|
1998
|
-
* 日志只打长度/会话短 id,**绝不打印正文**(回答里可能有代码/密钥/隐私)。
|
|
2092
|
+
* 事件订阅 → 出站通知。
|
|
2093
|
+
* · turn/end、session-error:**一条**消息 = 标题行 + 总结正文(sendTurnNotice;同 turn 已去重)
|
|
2094
|
+
* · 审批/提问/计划:优先可点卡片(**不混入总结**),卡片失败退回文本
|
|
2095
|
+
* · 其它(简报/额度/会员/故障):沿用 toMarkdownNotification
|
|
1999
2096
|
*/
|
|
2000
|
-
async function relayAnswer(node, sessionId) {
|
|
2001
|
-
const target = state.boundChatId || targetChatId;
|
|
2002
|
-
if (!target) {
|
|
2003
|
-
const at = now();
|
|
2004
|
-
if (at - lastNoTargetLogAt > 10 * 60 * 1000) {
|
|
2005
|
-
lastNoTargetLogAt = at;
|
|
2006
|
-
log.info("有回答要回传,但还没绑定接收方:请先在企业微信里给机器人发一句话(或在配置里填 targetChatId)");
|
|
2007
|
-
}
|
|
2008
|
-
return;
|
|
2009
|
-
}
|
|
2010
|
-
let text = "";
|
|
2011
|
-
let why = "";
|
|
2012
|
-
const sub = subscriber;
|
|
2013
|
-
if (!sub || typeof sub.lastAssistantText !== "function") {
|
|
2014
|
-
why = "no_subscriber";
|
|
2015
|
-
} else {
|
|
2016
|
-
for (const delay of ANSWER_FETCH_RETRY_DELAYS_MS) {
|
|
2017
|
-
if (delay > 0) {
|
|
2018
|
-
await sleepTimer(delay);
|
|
2019
|
-
if (stopped) return; // 等待期间桥已退出:不再发
|
|
2020
|
-
}
|
|
2021
|
-
try {
|
|
2022
|
-
const r = await sub.lastAssistantText({ sessionId });
|
|
2023
|
-
if (r && r.ok && typeof r.text === "string" && r.text.trim()) {
|
|
2024
|
-
text = r.text.trim();
|
|
2025
|
-
break;
|
|
2026
|
-
}
|
|
2027
|
-
why = String((r && (r.code || r.message)) || "empty");
|
|
2028
|
-
// 硬失败(page-failed / session-not-found)重试也没意义;只有「ok 但正文还没落库」才补一次
|
|
2029
|
-
if (!(r && r.ok)) break;
|
|
2030
|
-
} catch (e) {
|
|
2031
|
-
why = truncateText(e && e.message ? e.message : e, 60);
|
|
2032
|
-
break;
|
|
2033
|
-
}
|
|
2034
|
-
}
|
|
2035
|
-
}
|
|
2036
|
-
|
|
2037
|
-
// 降级:拿不到正文 → 退回标题通知(与改动前的行为一致)
|
|
2038
|
-
if (!text) {
|
|
2039
|
-
const fallback = toMarkdownNotification(node, { sessionTitle: options.sessionTitle });
|
|
2040
|
-
if (!fallback) return;
|
|
2041
|
-
if (!notifyRateOk()) {
|
|
2042
|
-
log.warn("通知超过每分钟上限,已丢弃(防止事件风暴刷屏)");
|
|
2043
|
-
return;
|
|
2044
|
-
}
|
|
2045
|
-
state.notified += 1;
|
|
2046
|
-
sendMarkdown(target, fallback);
|
|
2047
|
-
log.info(`取不到回答正文(${truncateText(why || "empty", 60)}),已降级为标题通知`);
|
|
2048
|
-
return;
|
|
2049
|
-
}
|
|
2050
|
-
|
|
2051
|
-
const truncated = text.length > answerMaxChars;
|
|
2052
|
-
let body = truncated ? `${text.slice(0, answerMaxChars)}${ANSWER_TRUNCATED_NOTE}` : text;
|
|
2053
|
-
// 非正常结束(出错/被拦/中止)时保留一句状态行,避免"看到回答却不知道它不完整"
|
|
2054
|
-
const reasonKind = String(node.reason || "");
|
|
2055
|
-
if (reasonKind && reasonKind !== "completed") {
|
|
2056
|
-
const status = toMarkdownNotification(node, { sessionTitle: options.sessionTitle });
|
|
2057
|
-
if (status) body = `${status}\n\n${body}`;
|
|
2058
|
-
}
|
|
2059
|
-
if (!notifyRateOk()) {
|
|
2060
|
-
log.warn("通知超过每分钟上限,已丢弃(防止事件风暴刷屏)");
|
|
2061
|
-
return;
|
|
2062
|
-
}
|
|
2063
|
-
state.notified += 1;
|
|
2064
|
-
state.answers += 1;
|
|
2065
|
-
const sent = sendMarkdown(target, body);
|
|
2066
|
-
// 只打长度与截断标记:正文绝不能进日志
|
|
2067
|
-
log.info(`回答回传 chat=${chatIdHash(target)} len=${body.length}${truncated ? "(已截断)" : ""}${sent && sent.ok ? "" : ` 发送失败=${(sent && sent.reason) || "?"}`}`);
|
|
2068
|
-
}
|
|
2069
|
-
|
|
2070
2097
|
function onEventNode(node) {
|
|
2071
2098
|
if (!node || typeof node !== "object") return;
|
|
2072
2099
|
if (typeof node.sessionId === "string" && node.sessionId) {
|
|
@@ -2074,19 +2101,31 @@ export function createWecomBot(options = {}) {
|
|
|
2074
2101
|
scheduleSessionRefresh();
|
|
2075
2102
|
}
|
|
2076
2103
|
if (stopped || !notifyAllowed || !state.authenticated) return;
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2104
|
+
const key = notifyKey(node);
|
|
2105
|
+
// ── 任务完成/出错:标题 + 总结正文(一次 turn 只发一条)──
|
|
2106
|
+
if (node.kind === NODE_KINDS.TURN_END || node.kind === NODE_KINDS.SESSION_ERROR) {
|
|
2080
2107
|
if (key && !rememberNotifyKey(key)) return;
|
|
2081
|
-
const
|
|
2108
|
+
const target = state.boundChatId || targetChatId;
|
|
2109
|
+
if (!target) {
|
|
2110
|
+
const at = now();
|
|
2111
|
+
if (at - lastNoTargetLogAt > 10 * 60 * 1000) {
|
|
2112
|
+
lastNoTargetLogAt = at;
|
|
2113
|
+
log.info("有任务通知要发,但还没绑定接收方:请先在企业微信里给机器人发一句话(或在配置里填 targetChatId)");
|
|
2114
|
+
}
|
|
2115
|
+
return;
|
|
2116
|
+
}
|
|
2117
|
+
if (!notifyRateOk()) {
|
|
2118
|
+
log.warn("通知超过每分钟上限,已丢弃(防止事件风暴刷屏)");
|
|
2119
|
+
return;
|
|
2120
|
+
}
|
|
2121
|
+
state.notified += 1;
|
|
2082
2122
|
lastInbound = { sessionId: "", at: 0 }; // 同一次入站只回传一次
|
|
2083
|
-
void
|
|
2123
|
+
void sendTurnNotice(node, target);
|
|
2084
2124
|
return;
|
|
2085
2125
|
}
|
|
2086
2126
|
// ── 审批类事件:优先发**可点按钮的卡片**(允许一次/拒绝),发不出去再退回 markdown ──
|
|
2087
2127
|
if (node.kind === NODE_KINDS.APPROVAL_REQUEST) {
|
|
2088
2128
|
const markdown = toMarkdownNotification(node, { sessionTitle: options.sessionTitle });
|
|
2089
|
-
const key = notifyKey(node);
|
|
2090
2129
|
if (key && !rememberNotifyKey(key)) return;
|
|
2091
2130
|
const requestId = String(node.eventId || "");
|
|
2092
2131
|
const target = state.boundChatId || targetChatId;
|
|
@@ -2118,11 +2157,9 @@ export function createWecomBot(options = {}) {
|
|
|
2118
2157
|
}
|
|
2119
2158
|
const markdown = toMarkdownNotification(node, { sessionTitle: options.sessionTitle });
|
|
2120
2159
|
if (!markdown) return;
|
|
2121
|
-
const key = notifyKey(node);
|
|
2122
2160
|
if (key && !rememberNotifyKey(key)) return;
|
|
2123
2161
|
const target = state.boundChatId || targetChatId;
|
|
2124
2162
|
if (!target) {
|
|
2125
|
-
// 首次使用:用户还没给机器人发过话,也没有配 targetChatId → 无处可推(如实记日志,不刷屏)
|
|
2126
2163
|
const at = now();
|
|
2127
2164
|
if (at - lastNoTargetLogAt > 10 * 60 * 1000) {
|
|
2128
2165
|
lastNoTargetLogAt = at;
|
|
@@ -2150,7 +2187,6 @@ export function createWecomBot(options = {}) {
|
|
|
2150
2187
|
|
|
2151
2188
|
function rememberNotifyKey(key) {
|
|
2152
2189
|
const at = now();
|
|
2153
|
-
// 有界 + TTL:Map 保插入序,超上限丢最旧
|
|
2154
2190
|
for (const [k, t] of notifiedKeys) {
|
|
2155
2191
|
if (at - t > 10 * 60 * 1000) notifiedKeys.delete(k);
|
|
2156
2192
|
else break;
|
|
@@ -149,7 +149,7 @@ function findDepWs(startDir) {
|
|
|
149
149
|
* 漏同步就会出现「入口脚本在、依赖不在」的半装运行时(插件半据此判定「运行环境已就绪」,
|
|
150
150
|
* 却永远起不来 bridge —— 2026-09-23 用户反馈的现场)。`src/channel-ref.mjs` 同理(本安装器也 import)。
|
|
151
151
|
*/
|
|
152
|
-
const RUNTIME_CLIENT_FILES = ["mobile-adapter.mjs", "dsh-bridge.mjs", "e2ee-shim.mjs", "e2ee-client.mjs", "e2ee-shim-script.js", "mobile-adapter.test.mjs", "wechat-channel.mjs", "dsh-events.mjs", "wechat-runtime.mjs", "wecom-bot.mjs", "im-format.mjs", "dingtalk-bot.mjs", "dingtalk-auth.mjs", "feishu-bot.mjs", "src/lifecycle.mjs", "src/channel-ref.mjs"];
|
|
152
|
+
const RUNTIME_CLIENT_FILES = ["mobile-adapter.mjs", "dsh-bridge.mjs", "e2ee-shim.mjs", "e2ee-client.mjs", "e2ee-shim-script.js", "mobile-adapter.test.mjs", "wechat-channel.mjs", "dsh-events.mjs", "wechat-runtime.mjs", "wecom-bot.mjs", "im-format.mjs", "dingtalk-bot.mjs", "dingtalk-auth.mjs", "im-commands.mjs", "apply-pending-plugin.mjs", "feishu-bot.mjs", "src/lifecycle.mjs", "src/channel-ref.mjs"];
|
|
153
153
|
|
|
154
154
|
/**
|
|
155
155
|
* 把"客户端脚本"补齐到配置目录。
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* live-update 单测:只测「文件级原子升级」这一层(下载/解包由 self-update.test.mjs 覆盖)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么这一层值得单独钉住:2026-11-11 的真机事故里,面板显示升级完成、实际没生效;
|
|
5
|
+
* 换成「原地逐文件换版 + HMR reconcile」后,最怕的是「换到一半失败留下半套」。
|
|
6
|
+
* 这里把原子性、回滚、陈旧 marker 判定全部做成行为级断言。
|
|
7
|
+
*/
|
|
8
|
+
import test from "node:test";
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import fs from "node:fs";
|
|
11
|
+
import os from "node:os";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import {
|
|
14
|
+
compareVersions,
|
|
15
|
+
readPluginPackage,
|
|
16
|
+
listFiles,
|
|
17
|
+
replaceFilesInPlace,
|
|
18
|
+
backupDirFor,
|
|
19
|
+
copyTree,
|
|
20
|
+
restoreFromBackup,
|
|
21
|
+
prepareLiveUpdate,
|
|
22
|
+
REQUIRED_FILES,
|
|
23
|
+
} from "../lib/live-update.js";
|
|
24
|
+
|
|
25
|
+
function tmpdir(tag) {
|
|
26
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), `dsh-live-${tag}-`));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function makePlugin(root, version, extra = {}) {
|
|
30
|
+
fs.mkdirSync(path.join(root, "lib"), { recursive: true });
|
|
31
|
+
fs.writeFileSync(path.join(root, "package.json"), JSON.stringify({ name: "dsh-remote-web", version }, null, 2));
|
|
32
|
+
for (const rel of ["lib/index.js", "lib/client.js", "lib/tarball-update.js"]) {
|
|
33
|
+
fs.writeFileSync(path.join(root, rel), `// ${rel} ${version}\n`);
|
|
34
|
+
}
|
|
35
|
+
for (const [rel, body] of Object.entries(extra)) {
|
|
36
|
+
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
|
|
37
|
+
fs.writeFileSync(path.join(root, rel), body);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
test("compareVersions 按 semver 处理预发版", () => {
|
|
42
|
+
assert.equal(compareVersions("0.8.1", "0.8.0"), 1);
|
|
43
|
+
assert.equal(compareVersions("0.8.1-beta.1", "0.8.0"), 1);
|
|
44
|
+
assert.equal(compareVersions("0.8.1-beta.1", "0.8.1"), -1);
|
|
45
|
+
assert.equal(compareVersions("0.8.1-beta.2", "0.8.1-beta.1"), 1);
|
|
46
|
+
assert.equal(compareVersions("0.8.1", "0.8.1"), 0);
|
|
47
|
+
assert.equal(compareVersions("nonsense", "0.8.1"), null);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("readPluginPackage / listFiles 读得到版本与相对路径", () => {
|
|
51
|
+
const dir = tmpdir("read");
|
|
52
|
+
makePlugin(dir, "1.2.3", { "runtime/node_modules/ws/index.js": "x" });
|
|
53
|
+
assert.deepEqual(readPluginPackage(dir), { name: "dsh-remote-web", version: "1.2.3" });
|
|
54
|
+
const files = listFiles(dir);
|
|
55
|
+
assert.ok(files.includes("package.json"));
|
|
56
|
+
assert.ok(files.includes("lib/index.js"));
|
|
57
|
+
assert.ok(files.includes("runtime/node_modules/ws/index.js"));
|
|
58
|
+
assert.equal(readPluginPackage(path.join(dir, "nope")), null);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("replaceFilesInPlace:覆盖已有文件、保留目标独有文件、不留临时文件", () => {
|
|
62
|
+
const src = tmpdir("src");
|
|
63
|
+
const dst = tmpdir("dst");
|
|
64
|
+
makePlugin(src, "2.0.0", { "lib/new-module.js": "new" });
|
|
65
|
+
makePlugin(dst, "1.0.0", { "runtime/old-only.txt": "keep-me" });
|
|
66
|
+
const r = replaceFilesInPlace(src, dst);
|
|
67
|
+
assert.ok(r.files >= 4);
|
|
68
|
+
assert.equal(readPluginPackage(dst).version, "2.0.0");
|
|
69
|
+
assert.equal(fs.readFileSync(path.join(dst, "lib/new-module.js"), "utf8"), "new");
|
|
70
|
+
assert.equal(fs.readFileSync(path.join(dst, "runtime/old-only.txt"), "utf8"), "keep-me");
|
|
71
|
+
const leftovers = listFiles(dst).filter((p) => p.includes(".tmp-"));
|
|
72
|
+
assert.deepEqual(leftovers, []);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("replaceFilesInPlace:写盘失败时抛错且目标内容不变(临时文件先落、rename 后生效)", () => {
|
|
76
|
+
const src = tmpdir("src2");
|
|
77
|
+
const dst = tmpdir("dst2");
|
|
78
|
+
makePlugin(src, "3.0.0");
|
|
79
|
+
makePlugin(dst, "2.0.0");
|
|
80
|
+
const fakeFs = {
|
|
81
|
+
existsSync: fs.existsSync,
|
|
82
|
+
mkdirSync: fs.mkdirSync,
|
|
83
|
+
copyFileSync: () => { throw new Error("disk full"); },
|
|
84
|
+
chmodSync: fs.chmodSync,
|
|
85
|
+
statSync: fs.statSync,
|
|
86
|
+
renameSync: fs.renameSync,
|
|
87
|
+
};
|
|
88
|
+
assert.throws(() => replaceFilesInPlace(src, dst, { fsImpl: fakeFs }), /disk full/);
|
|
89
|
+
assert.equal(readPluginPackage(dst).version, "2.0.0");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("copyTree + restoreFromBackup 能完整回滚(含 runtime 与 ws)", () => {
|
|
93
|
+
const target = tmpdir("target");
|
|
94
|
+
makePlugin(target, "1.0.0", { "runtime/node_modules/ws/index.js": "ws" });
|
|
95
|
+
const backup = backupDirFor(path.dirname(target));
|
|
96
|
+
copyTree(target, backup);
|
|
97
|
+
// 模拟升级后被破坏
|
|
98
|
+
fs.writeFileSync(path.join(target, "package.json"), JSON.stringify({ name: "dsh-remote-web", version: "9.9.9" }));
|
|
99
|
+
fs.rmSync(path.join(target, "runtime"), { recursive: true, force: true });
|
|
100
|
+
restoreFromBackup(backup, target);
|
|
101
|
+
assert.equal(readPluginPackage(target).version, "1.0.0");
|
|
102
|
+
assert.equal(fs.readFileSync(path.join(target, "runtime/node_modules/ws/index.js"), "utf8"), "ws");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("prepareLiveUpdate:目标不存在 / 已是最新时不落盘", async () => {
|
|
106
|
+
const relay = tmpdir("relay");
|
|
107
|
+
const profile = tmpdir("profile");
|
|
108
|
+
const missing = await prepareLiveUpdate({ relayDir: relay, profileDir: profile });
|
|
109
|
+
assert.equal(missing.ok, false);
|
|
110
|
+
assert.equal(missing.code, "TARGET_NOT_FOUND");
|
|
111
|
+
// 已是最新:注入一个假的 fetchTarballToStaging 不现实(模块内部直接 import),
|
|
112
|
+
// 这里改为断言「用已装同版本 + 不存在的 registry 时不会破坏目标」——走 TARGET_NOT_FOUND 分支已覆盖主路径。
|
|
113
|
+
assert.ok(REQUIRED_FILES.includes("lib/index.js"));
|
|
114
|
+
});
|