@mrrisega/dsh-remote 0.7.10-beta.3 → 0.7.12-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/agent/README.md +412 -0
- package/clients/dsh-remote/agent/agent-e2e-codex.mjs +185 -0
- package/clients/dsh-remote/agent/agent-e2e-real.mjs +443 -0
- package/clients/dsh-remote/agent/codex.mjs +736 -0
- package/clients/dsh-remote/agent/detect.mjs +399 -0
- package/clients/dsh-remote/agent/events.mjs +511 -0
- package/clients/dsh-remote/agent/gemini.mjs +503 -0
- package/clients/dsh-remote/agent/index.mjs +339 -0
- package/clients/dsh-remote/agent/opencode.mjs +628 -0
- package/clients/dsh-remote/agent/permission-bridge.mjs +534 -0
- package/clients/dsh-remote/agent/proc.mjs +179 -0
- package/clients/dsh-remote/agent/version.mjs +148 -0
- package/clients/dsh-remote/approval-coordinator.mjs +150 -0
- package/clients/dsh-remote/decision-poller.mjs +235 -0
- package/clients/dsh-remote/dsh-bridge.mjs +220 -1
- package/clients/dsh-remote/im-format.mjs +372 -0
- package/clients/dsh-remote/push-reporter.mjs +54 -0
- package/clients/dsh-remote/test/agent-codex.test.mjs +535 -0
- package/clients/dsh-remote/test/agent-detect.test.mjs +390 -0
- package/clients/dsh-remote/test/agent-events.test.mjs +340 -0
- package/clients/dsh-remote/test/agent-gemini.test.mjs +355 -0
- package/clients/dsh-remote/test/agent-jsonl-driver.test.mjs +165 -0
- package/clients/dsh-remote/test/agent-opencode.test.mjs +369 -0
- package/clients/dsh-remote/test/agent-permission-bridge.test.mjs +340 -0
- package/clients/dsh-remote/test/agent-proc.test.mjs +175 -0
- package/clients/dsh-remote/test/agent-runtime.test.mjs +282 -0
- package/clients/dsh-remote/test/agent-version.test.mjs +122 -0
- package/clients/dsh-remote/test/approval-coordinator.test.mjs +192 -0
- package/clients/dsh-remote/test/decision-poller.test.mjs +328 -0
- package/clients/dsh-remote/test/im-format.test.mjs +229 -0
- package/clients/dsh-remote/test/upstream-discovery.test.mjs +203 -2
- package/clients/dsh-remote/test/wecom-bot.test.mjs +1718 -0
- package/clients/dsh-remote/upstream-discovery.mjs +495 -43
- package/clients/dsh-remote/wecom-bot.mjs +2670 -0
- package/dsh-setup.mjs +95 -9
- package/package.json +2 -2
- package/packages/dsh-remote-web/lib/client.js +1377 -292
- package/packages/dsh-remote-web/lib/index.js +419 -12
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/README.md +412 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/agent-e2e-codex.mjs +185 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/agent-e2e-real.mjs +443 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/codex.mjs +736 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/detect.mjs +399 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/events.mjs +511 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/gemini.mjs +503 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/index.mjs +339 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/opencode.mjs +628 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/permission-bridge.mjs +534 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/proc.mjs +179 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/agent/version.mjs +148 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/approval-coordinator.mjs +150 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/decision-poller.mjs +235 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +258 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/push-reporter.mjs +533 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wecom-bot.mjs +2444 -0
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +18 -3
- package/packages/dsh-remote-web/test/activation-entry.test.mjs +11 -1
- package/packages/dsh-remote-web/test/announcements-ui.test.mjs +8 -3
- package/packages/dsh-remote-web/test/connect-ui.test.mjs +16 -4
- package/packages/dsh-remote-web/test/im-bots.test.mjs +400 -0
- package/packages/dsh-remote-web/test/lib-panel-ia.mjs +98 -0
- package/packages/dsh-remote-web/test/login-fast.test.mjs +104 -0
- package/packages/dsh-remote-web/test/login-selfheal-ui.test.mjs +17 -6
- package/packages/dsh-remote-web/test/panel-ia.test.mjs +996 -0
- package/packages/dsh-remote-web/test/password-reset.test.mjs +11 -1
- package/packages/dsh-remote-web/test/plugin-link.test.mjs +78 -0
- package/packages/dsh-remote-web/test/purchase-agreement-ui.test.mjs +14 -5
- package/packages/dsh-remote-web/test/remote-access-ui.test.mjs +69 -119
- package/packages/dsh-remote-web/test/self-manage.test.mjs +5 -1
- package/packages/dsh-remote-web/test/settings-entry.test.mjs +42 -25
- package/packages/dsh-remote-web/test/sms-captcha-ui.test.mjs +15 -2
- package/packages/dsh-remote-web/test/telemetry.test.mjs +6 -4
- package/packages/dsh-remote-web/test/wechat-bot-ui.test.mjs +57 -130
|
@@ -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", "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", "src/lifecycle.mjs", "src/channel-ref.mjs"];
|
|
153
153
|
|
|
154
154
|
/**
|
|
155
155
|
* 把"客户端脚本"补齐到配置目录。
|
|
@@ -2963,7 +2963,8 @@ function copyPluginIntoProfile(profileDir, pluginDir) {
|
|
|
2963
2963
|
* 这里只认 dsh 自己那条解析路径能不能走通:
|
|
2964
2964
|
* ① 读得到 package.json 且 name 正确(cordis 加载器 + @deepseek-ai/dsh-client-modules 靠它读 dsh.client);
|
|
2965
2965
|
* ② 读得到 lib/index.js(节点半入口);
|
|
2966
|
-
* ③ 要么 realpath
|
|
2966
|
+
* ③ 要么 realpath 指回本地拷贝目录(链接形态),要么**整目录拷贝**且
|
|
2967
|
+
* `lib/index.js` 与 `package.json` 都与本地拷贝逐字节一致(拷贝形态)。
|
|
2967
2968
|
*/
|
|
2968
2969
|
function pluginLinkUsable(nmPlugin, pluginLocalDir) {
|
|
2969
2970
|
try {
|
|
@@ -2977,9 +2978,23 @@ function pluginLinkUsable(nmPlugin, pluginLocalDir) {
|
|
|
2977
2978
|
if (real) {
|
|
2978
2979
|
try { if (real === fs.realpathSync(pluginLocalDir)) return true; } catch { /* 本地目录不在 → 看内容 */ }
|
|
2979
2980
|
}
|
|
2981
|
+
/**
|
|
2982
|
+
* ★ 2026-11-07(桌面端实测):拷贝形态**必须连 package.json 一起比对**,只看入口会漏掉旧版残留。
|
|
2983
|
+
*
|
|
2984
|
+
* 桌面 profile 用 pnpm `nodeLinker: hoisted`,`file:` 依赖会被**复制**进 profile 的
|
|
2985
|
+
* `node_modules/<id>`(不是链接)。npx 安装器随后只替换了 `dsh-remote-web-plugin/`
|
|
2986
|
+
* (file: 的源目录)—— 那份复制品还是上一版。而版本号只写在 package.json 里,
|
|
2987
|
+
* 相邻 beta 之间的 `lib/index.js` 往往逐字节相同 ⇒ 旧判据判「已就绪」,
|
|
2988
|
+
* 桌面端继续 import 旧的 package.json:面板版本号是旧的、`/self/update-check` 一直
|
|
2989
|
+
* `outdated:true`,用户反复 npx 都"装不上新版"(实测 beta.3 → beta.10 复现)。
|
|
2990
|
+
* 拷贝形态本就该与源目录完全一致,逐字节比对 package.json 即可识别,代价是一次 re-link。
|
|
2991
|
+
*/
|
|
2980
2992
|
try {
|
|
2981
|
-
|
|
2993
|
+
const sameEntry = fs.readFileSync(path.join(nmPlugin, "lib", "index.js"), "utf8")
|
|
2982
2994
|
=== fs.readFileSync(path.join(pluginLocalDir, "lib", "index.js"), "utf8");
|
|
2995
|
+
if (!sameEntry) return false;
|
|
2996
|
+
return fs.readFileSync(path.join(nmPlugin, "package.json"))
|
|
2997
|
+
.equals(fs.readFileSync(path.join(pluginLocalDir, "package.json")));
|
|
2983
2998
|
} catch { return false; }
|
|
2984
2999
|
}
|
|
2985
3000
|
|
|
@@ -21,6 +21,7 @@ import assert from "node:assert/strict";
|
|
|
21
21
|
import { readFileSync } from "node:fs";
|
|
22
22
|
import test from "node:test";
|
|
23
23
|
import vm from "node:vm";
|
|
24
|
+
import { openEntry } from "./lib-panel-ia.mjs";
|
|
24
25
|
|
|
25
26
|
const SOURCE = readFileSync(new URL("../lib/client.js", import.meta.url), "utf8");
|
|
26
27
|
|
|
@@ -90,6 +91,7 @@ function loadPlugin(opts = {}) {
|
|
|
90
91
|
const clipboard = [];
|
|
91
92
|
const states = [];
|
|
92
93
|
let hook = 0;
|
|
94
|
+
let iaOpened = false;
|
|
93
95
|
let effectCursor = 0;
|
|
94
96
|
let effectSlots = [];
|
|
95
97
|
let pending = [];
|
|
@@ -228,7 +230,15 @@ function loadPlugin(opts = {}) {
|
|
|
228
230
|
counts() { return { connCalls, keyCalls, sessCalls }; },
|
|
229
231
|
render() {
|
|
230
232
|
hook = 0; effectCursor = 0; pending = [];
|
|
231
|
-
|
|
233
|
+
let tree = registered.get("dsh-remote")({ close() {} });
|
|
234
|
+
// IA 重做(2026-11-07):访问卡/激活引导在「手机访问」弹层里 —— 按真实用户路径点开入口卡。
|
|
235
|
+
if (!iaOpened) {
|
|
236
|
+
iaOpened = true;
|
|
237
|
+
if (openEntry(tree, opts.autoModal || "mobile")) {
|
|
238
|
+
hook = 0; effectCursor = 0; pending = [];
|
|
239
|
+
tree = registered.get("dsh-remote")({ close() {} });
|
|
240
|
+
}
|
|
241
|
+
}
|
|
232
242
|
const todos = pending; pending = [];
|
|
233
243
|
for (const t of todos) {
|
|
234
244
|
const prev = effectSlots[t.index];
|
|
@@ -17,6 +17,7 @@ import assert from "node:assert/strict";
|
|
|
17
17
|
import { readFileSync } from "node:fs";
|
|
18
18
|
import test from "node:test";
|
|
19
19
|
import vm from "node:vm";
|
|
20
|
+
import { openEntry, clickChannelRow } from "./lib-panel-ia.mjs";
|
|
20
21
|
|
|
21
22
|
const SOURCE = readFileSync(new URL("../lib/client.js", import.meta.url), "utf8");
|
|
22
23
|
|
|
@@ -494,9 +495,13 @@ test("微信机器人引导:两步走引导块在能力清单之上(DOM 顺
|
|
|
494
495
|
const plugin = loadPlugin();
|
|
495
496
|
let tree = await plugin.settle();
|
|
496
497
|
// 进入微信机器人 tab(未登录时不渲染这个 tab,所以先确认它在)
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
498
|
+
// IA 重做(2026-11-07):顶部 tab 已删除 —— 微信通道在「通知与消息通道」弹层里,
|
|
499
|
+
// 主面板只保留入口卡;这里断言入口仍在(不再要求 tab)。
|
|
500
|
+
assert.ok(textHas(tree, "通知与消息通道"), "主面板应有「通知与消息通道」入口卡(登录不再隐藏功能)");
|
|
501
|
+
// IA 重做:通道入口 → 平台列表 → 微信机器人一行 → 绑定面板(原来的第三 tab 已删)
|
|
502
|
+
assert.ok(openEntry(tree, "channel"), "应能点开通道弹层");
|
|
503
|
+
tree = await plugin.settle();
|
|
504
|
+
assert.ok(clickChannelRow(tree, "微信机器人"), "通道弹层里应能点进微信机器人");
|
|
500
505
|
tree = await plugin.settle();
|
|
501
506
|
|
|
502
507
|
const embed = find(tree, (n) => n.props?.className === "dru-wx-embed");
|
|
@@ -13,6 +13,7 @@ import assert from "node:assert/strict";
|
|
|
13
13
|
import { readFileSync } from "node:fs";
|
|
14
14
|
import test from "node:test";
|
|
15
15
|
import vm from "node:vm";
|
|
16
|
+
import { openEntry } from "./lib-panel-ia.mjs";
|
|
16
17
|
|
|
17
18
|
const SOURCE = readFileSync(new URL("../lib/client.js", import.meta.url), "utf8");
|
|
18
19
|
|
|
@@ -90,6 +91,7 @@ function loadPlugin(opts = {}) {
|
|
|
90
91
|
const clipboard = [];
|
|
91
92
|
const states = [];
|
|
92
93
|
let hook = 0;
|
|
94
|
+
let iaOpened = false;
|
|
93
95
|
let effectCursor = 0;
|
|
94
96
|
let effectSlots = [];
|
|
95
97
|
let pending = [];
|
|
@@ -239,7 +241,15 @@ function loadPlugin(opts = {}) {
|
|
|
239
241
|
/** 渲染一轮并执行本轮需要运行的 effects(含 cleanup)。 */
|
|
240
242
|
render() {
|
|
241
243
|
hook = 0; effectCursor = 0; pending = [];
|
|
242
|
-
|
|
244
|
+
let tree = registered.get("dsh-remote")({ close() {} });
|
|
245
|
+
// IA 重做(2026-11-07):访问卡/激活引导在「手机访问」弹层里 —— 按真实用户路径点开入口卡。
|
|
246
|
+
if (!iaOpened) {
|
|
247
|
+
iaOpened = true;
|
|
248
|
+
if (openEntry(tree, opts.autoModal || "mobile")) {
|
|
249
|
+
hook = 0; effectCursor = 0; pending = [];
|
|
250
|
+
tree = registered.get("dsh-remote")({ close() {} });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
243
253
|
const todos = pending; pending = [];
|
|
244
254
|
for (const t of todos) {
|
|
245
255
|
const prev = effectSlots[t.index];
|
|
@@ -378,10 +388,12 @@ test("轮询策略:document.hidden 时暂停短轮询,回前台立即补一
|
|
|
378
388
|
});
|
|
379
389
|
|
|
380
390
|
test("面板注册带注册来源 reg_source=panel_register(增长约定:电脑端 vs 手机端网页注册)", async () => {
|
|
381
|
-
const plugin = loadPlugin({ status: NOT_LOGGED });
|
|
391
|
+
const plugin = loadPlugin({ status: NOT_LOGGED, autoModal: "account" });
|
|
382
392
|
let tree = await plugin.settle();
|
|
383
|
-
|
|
384
|
-
|
|
393
|
+
// IA 重做(2026-11-07):登录/注册从顶部 tab 变成弹层内的分段控件(.dru-ia-seg-btn)
|
|
394
|
+
const regTab = find(tree, (n) => String(n.props?.className || "").split(/\s+/).includes("dru-ia-seg-btn")
|
|
395
|
+
&& (n.children || []).some((c) => typeof c === "string" && c.includes("注册")));
|
|
396
|
+
assert.ok(regTab, "应有「注册」分段按钮");
|
|
385
397
|
regTab.props.onClick();
|
|
386
398
|
tree = await plugin.settle();
|
|
387
399
|
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
// 🤖 IM 机器人通道(企业微信智能机器人)—— 节点半(lib/index.js)三条本地 API 的契约回归。
|
|
2
|
+
//
|
|
3
|
+
// 契约(与桥半约定,字段名不要改):
|
|
4
|
+
// <relayDir>/im-bots.json 插件写、桥读 {version:1, wecom:{enabled,botId,secret,url,targetChatId}}
|
|
5
|
+
// <relayDir>/im-bot-status.json 桥写、插件只读 {updatedAt, wecom:{...}}
|
|
6
|
+
// GET /dsh-remote/im-bots → {ok, wecom:{enabled,botId,hasSecret,url,targetChatId}, status}
|
|
7
|
+
// PUT /dsh-remote/im-bots {wecom:{...}} → 保存(空 secret = 不修改;空 targetChatId = 不设置)
|
|
8
|
+
// POST /dsh-remote/im-bots/test → 保存(可选)后轮询状态 ≤10s
|
|
9
|
+
// ⚠️ **secret 永不返回浏览器**(GET/PUT/test 三个响应里都只有 hasSecret)。
|
|
10
|
+
//
|
|
11
|
+
// 为什么这些用例必须存在:面板只回显 hasSecret,secret 一旦被误下发,等于把机器人凭据
|
|
12
|
+
// 暴露给任何能打开本机 dsh web 页面的人(哪怕它只是一台共享电脑)。
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
import http from "node:http";
|
|
15
|
+
import { mkdtemp, readFile, stat, writeFile } from "node:fs/promises";
|
|
16
|
+
import os from "node:os";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
import test from "node:test";
|
|
19
|
+
import { apply } from "../lib/index.js";
|
|
20
|
+
|
|
21
|
+
/** 装插件 → 起一个只做本地路由分发的 host(与 announcements-proxy.test.mjs 同一形态)。 */
|
|
22
|
+
function boot(relayDir, config = {}) {
|
|
23
|
+
const routes = [];
|
|
24
|
+
apply({
|
|
25
|
+
webServer: { register(rt) { routes.push(rt); return () => {}; } },
|
|
26
|
+
effect(reg) { return reg(); },
|
|
27
|
+
logger: { info() {}, warn() {} },
|
|
28
|
+
}, { relayDir, ...config });
|
|
29
|
+
const host = http.createServer((req, res) => {
|
|
30
|
+
const url = new URL(req.url, "http://x");
|
|
31
|
+
const rt = routes.find((r) => (r.kind === "prefix"
|
|
32
|
+
? url.pathname === r.path || url.pathname.startsWith(r.path + "/")
|
|
33
|
+
: url.pathname === r.path));
|
|
34
|
+
(rt ? rt.handler : ((_r, rs) => { rs.writeHead(404); rs.end(); }))(req, res);
|
|
35
|
+
});
|
|
36
|
+
return host;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const tmpRelay = () => mkdtemp(path.join(os.tmpdir(), "dsh-imbot-"));
|
|
40
|
+
|
|
41
|
+
async function withHost(relayDir, config, fn) {
|
|
42
|
+
const host = boot(relayDir, config);
|
|
43
|
+
await new Promise((r) => host.listen(0, "127.0.0.1", r));
|
|
44
|
+
const base = `http://127.0.0.1:${host.address().port}`;
|
|
45
|
+
try { return await fn(base); } finally { await new Promise((r) => host.close(r)); }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const json = async (url, opts) => {
|
|
49
|
+
const r = await fetch(url, opts);
|
|
50
|
+
const body = await r.json().catch(() => null);
|
|
51
|
+
return { status: r.status, body };
|
|
52
|
+
};
|
|
53
|
+
const put = (base, wecom) => json(`${base}/dsh-remote/im-bots`, {
|
|
54
|
+
method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ wecom }),
|
|
55
|
+
});
|
|
56
|
+
const postTest = (base, wecom) => json(`${base}/dsh-remote/im-bots/test`, {
|
|
57
|
+
method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(wecom === undefined ? {} : { wecom }),
|
|
58
|
+
});
|
|
59
|
+
const readCfg = async (relayDir) => JSON.parse(await readFile(path.join(relayDir, "im-bots.json"), "utf8"));
|
|
60
|
+
const writeStatus = (relayDir, wecom, extra = {}) =>
|
|
61
|
+
writeFile(path.join(relayDir, "im-bot-status.json"), JSON.stringify({ updatedAt: Date.now(), wecom, ...extra }));
|
|
62
|
+
|
|
63
|
+
// ───────────────────────────── GET ─────────────────────────────
|
|
64
|
+
|
|
65
|
+
test("GET /dsh-remote/im-bots:未配置时给完整空形态(面板据此显示「未配置」,不是报错)", async () => {
|
|
66
|
+
const relayDir = await tmpRelay();
|
|
67
|
+
await withHost(relayDir, {}, async (base) => {
|
|
68
|
+
const r = await json(`${base}/dsh-remote/im-bots`);
|
|
69
|
+
assert.equal(r.status, 200);
|
|
70
|
+
assert.equal(r.body.ok, true);
|
|
71
|
+
assert.deepEqual(r.body.wecom, { enabled: false, botId: "", hasSecret: false, url: "", targetChatId: "", sessionId: "", sessionTitle: "" },
|
|
72
|
+
"未配置 = 全空 + hasSecret:false(绝不出现 secret 字段;会话绑定也默认空 = 跟随活跃)");
|
|
73
|
+
assert.equal(r.body.status.connected, false);
|
|
74
|
+
assert.equal(r.body.status.lastError, "");
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("GET:**绝不返回 secret**(连字段名都不出现),只给 hasSecret", async () => {
|
|
79
|
+
const relayDir = await tmpRelay();
|
|
80
|
+
await withHost(relayDir, {}, async (base) => {
|
|
81
|
+
await put(base, { botId: "aibdemo1234", secret: "SECRET-PLAINTEXT-123", targetChatId: "chat-9" });
|
|
82
|
+
const r = await json(`${base}/dsh-remote/im-bots`);
|
|
83
|
+
const raw = JSON.stringify(r.body);
|
|
84
|
+
assert.ok(!raw.includes("SECRET-PLAINTEXT-123"), "★ 响应体里绝不能出现 secret 明文");
|
|
85
|
+
assert.ok(!raw.includes("secret"), "★ 连 secret 字段名都不下发(只给 hasSecret 布尔)");
|
|
86
|
+
assert.equal(r.body.wecom.hasSecret, true);
|
|
87
|
+
assert.equal(r.body.wecom.botId, "aibdemo1234");
|
|
88
|
+
assert.equal(r.body.wecom.targetChatId, "chat-9", "GET 回显 targetChatId(lead 补充契约)");
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("GET:状态文件是桥写的 → 只转发白名单字段(桥塞敏感字段也带不出去)", async () => {
|
|
93
|
+
const relayDir = await tmpRelay();
|
|
94
|
+
await withHost(relayDir, {}, async (base) => {
|
|
95
|
+
await writeStatus(relayDir, {
|
|
96
|
+
enabled: true, connected: true, authenticated: true, lastError: "", lastConnectedAt: 111,
|
|
97
|
+
lastMessageAt: 222, boundChatId: "chat-1", secret: "LEAKED-FROM-STATUS",
|
|
98
|
+
}, { updatedAt: 333, token: "LEAKED-TOKEN" });
|
|
99
|
+
const r = await json(`${base}/dsh-remote/im-bots`);
|
|
100
|
+
const raw = JSON.stringify(r.body);
|
|
101
|
+
assert.equal(r.body.status.connected, true);
|
|
102
|
+
assert.equal(r.body.status.boundChatId, "chat-1");
|
|
103
|
+
assert.ok(!raw.includes("LEAKED"), "★ 状态文件只按白名单挑字段,未知/敏感字段一律不外传");
|
|
104
|
+
assert.equal(r.body.updatedAt, 333);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// ───────────────────────────── PUT ─────────────────────────────
|
|
109
|
+
|
|
110
|
+
test("PUT:保存 Bot ID + Secret + targetChatId;文件 0600(仅本机可读),JSON 形状符合契约", async () => {
|
|
111
|
+
const relayDir = await tmpRelay();
|
|
112
|
+
await withHost(relayDir, {}, async (base) => {
|
|
113
|
+
const r = await put(base, { botId: "aibdemo1234", secret: "s3cret", targetChatId: "chat-9" });
|
|
114
|
+
assert.equal(r.status, 200);
|
|
115
|
+
assert.equal(r.body.ok, true);
|
|
116
|
+
assert.equal(r.body.wecom.hasSecret, true);
|
|
117
|
+
const file = path.join(relayDir, "im-bots.json");
|
|
118
|
+
const cfg = JSON.parse(await readFile(file, "utf8"));
|
|
119
|
+
assert.equal(cfg.version, 1);
|
|
120
|
+
assert.deepEqual(cfg.wecom, { enabled: true, botId: "aibdemo1234", secret: "s3cret", url: "", targetChatId: "chat-9", sessionId: "", sessionTitle: "", previewCardAt: 0 });
|
|
121
|
+
if (process.platform !== "win32") {
|
|
122
|
+
const st = await stat(file);
|
|
123
|
+
assert.equal(st.mode & 0o777, 0o600, "★ 配置文件必须 0600(含明文 secret)");
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("PUT:空 secret = **不修改**(用户只改 Bot ID 时不必重填),且 targetChatId 可单独清空", async () => {
|
|
129
|
+
const relayDir = await tmpRelay();
|
|
130
|
+
await withHost(relayDir, {}, async (base) => {
|
|
131
|
+
await put(base, { botId: "aibdemo1234", secret: "keep-me", targetChatId: "chat-9" });
|
|
132
|
+
const r = await put(base, { botId: "aibdemo5678", secret: "", targetChatId: "" });
|
|
133
|
+
assert.equal(r.status, 200);
|
|
134
|
+
const cfg = await readCfg(relayDir);
|
|
135
|
+
assert.equal(cfg.wecom.botId, "aibdemo5678", "Bot ID 已更新");
|
|
136
|
+
assert.equal(cfg.wecom.secret, "keep-me", "★ 空 secret 必须保留原值(面板不回显 secret,用户没法重填)");
|
|
137
|
+
assert.equal(cfg.wecom.targetChatId, "", "targetChatId 传空串 = 清空(留空 = 不设置)");
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("PUT:校验失败一律人话 + 稳定错误码(invalid_bot_id / missing_secret),且**不落盘半成品**", async () => {
|
|
142
|
+
const relayDir = await tmpRelay();
|
|
143
|
+
await withHost(relayDir, {}, async (base) => {
|
|
144
|
+
const bad = await put(base, { botId: "not-a-bot", secret: "x" });
|
|
145
|
+
assert.equal(bad.status, 400);
|
|
146
|
+
assert.equal(bad.body.error, "invalid_bot_id", "Bot ID 形如 aib... —— 抄错要当场拒绝");
|
|
147
|
+
assert.ok(String(bad.body.message).includes("aib"), "错误文案要告诉用户正确形状");
|
|
148
|
+
|
|
149
|
+
const noSecret = await put(base, { botId: "aibdemo1234" });
|
|
150
|
+
assert.equal(noSecret.status, 400);
|
|
151
|
+
assert.equal(noSecret.body.error, "missing_secret");
|
|
152
|
+
|
|
153
|
+
await assert.rejects(readFile(path.join(relayDir, "im-bots.json"), "utf8"),
|
|
154
|
+
"校验失败时绝不能写出半成品配置文件");
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("PUT:保留其它平台与未知字段(插件不认识 feishu,但没有权利删掉它)", async () => {
|
|
159
|
+
const relayDir = await tmpRelay();
|
|
160
|
+
await writeFile(path.join(relayDir, "im-bots.json"), JSON.stringify({
|
|
161
|
+
version: 1, feishu: { enabled: true, appId: "cli_x" }, wecom: { enabled: false, botId: "", secret: "", url: "", targetChatId: "" }, keep: 1,
|
|
162
|
+
}));
|
|
163
|
+
await withHost(relayDir, {}, async (base) => {
|
|
164
|
+
await put(base, { botId: "aibdemo1234", secret: "x" });
|
|
165
|
+
const cfg = await readCfg(relayDir);
|
|
166
|
+
assert.deepEqual(cfg.feishu, { enabled: true, appId: "cli_x" }, "其它平台的配置必须原样保留");
|
|
167
|
+
assert.equal(cfg.keep, 1, "未知顶层字段也保留(前向兼容)");
|
|
168
|
+
assert.equal(cfg.wecom.enabled, true);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("PUT:url 与 targetChatId 的边界(非法 url 拒绝;超长会话 ID 拒绝)", async () => {
|
|
173
|
+
const relayDir = await tmpRelay();
|
|
174
|
+
await withHost(relayDir, {}, async (base) => {
|
|
175
|
+
const badUrl = await put(base, { botId: "aibdemo1234", secret: "x", url: "ftp://x" });
|
|
176
|
+
assert.equal(badUrl.status, 400);
|
|
177
|
+
assert.equal(badUrl.body.error, "invalid_url");
|
|
178
|
+
const badChat = await put(base, { botId: "aibdemo1234", secret: "x", targetChatId: "c".repeat(201) });
|
|
179
|
+
assert.equal(badChat.status, 400);
|
|
180
|
+
assert.equal(badChat.body.error, "invalid_target_chat_id");
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// ───────────────────────────── POST /test ─────────────────────────────
|
|
185
|
+
|
|
186
|
+
test("POST /test:没有 secret → 400 missing_secret(不带凭据不可能是桥的问题)", async () => {
|
|
187
|
+
const relayDir = await tmpRelay();
|
|
188
|
+
await withHost(relayDir, {}, async (base) => {
|
|
189
|
+
const r = await postTest(base, { botId: "aibdemo1234" });
|
|
190
|
+
assert.equal(r.status, 400);
|
|
191
|
+
assert.equal(r.body.error, "missing_secret");
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("POST /test:桥写的状态显示已连接 → {ok:true, connected:true, authenticated}(≤10s 内回)", async () => {
|
|
196
|
+
const relayDir = await tmpRelay();
|
|
197
|
+
await withHost(relayDir, {}, async (base) => {
|
|
198
|
+
await put(base, { botId: "aibdemo1234", secret: "x", targetChatId: "" });
|
|
199
|
+
await writeStatus(relayDir, { enabled: true, connected: true, authenticated: true, lastError: "" });
|
|
200
|
+
const r = await postTest(base); // 不传 body = 测已保存的配置
|
|
201
|
+
assert.equal(r.status, 200);
|
|
202
|
+
assert.deepEqual(r.body, { ok: true, connected: true, authenticated: true, lastError: "" });
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("POST /test:桥明确报错 → 502 connect_failed,并把桥的 lastError 原文带给用户", async () => {
|
|
207
|
+
const relayDir = await tmpRelay();
|
|
208
|
+
await withHost(relayDir, {}, async (base) => {
|
|
209
|
+
await put(base, { botId: "aibdemo1234", secret: "x" });
|
|
210
|
+
await writeStatus(relayDir, { enabled: true, connected: false, authenticated: false, lastError: "invalid credential (errcode 40001)" });
|
|
211
|
+
const r = await postTest(base);
|
|
212
|
+
assert.equal(r.status, 502);
|
|
213
|
+
assert.equal(r.body.error, "connect_failed");
|
|
214
|
+
assert.ok(String(r.body.lastError).includes("40001"), "桥给的原因必须原样透出(用户才能拿去搜/找客服)");
|
|
215
|
+
assert.ok(String(r.body.message).includes("40001"), "人话文案里也要带上原因");
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("POST /test:状态一直不动 → 504 timeout(等待上限可配,测试用 300ms,不空等 10s)", async () => {
|
|
220
|
+
const relayDir = await tmpRelay();
|
|
221
|
+
await withHost(relayDir, { imBotsWaitMs: 300 }, async (base) => {
|
|
222
|
+
await put(base, { botId: "aibdemo1234", secret: "x" });
|
|
223
|
+
const t0 = Date.now();
|
|
224
|
+
const r = await postTest(base);
|
|
225
|
+
assert.equal(r.status, 504);
|
|
226
|
+
assert.equal(r.body.error, "timeout");
|
|
227
|
+
assert.ok(Date.now() - t0 < 5000, "必须尊重配置的等待上限(否则用例会真等 10s)");
|
|
228
|
+
assert.ok(String(r.body.message).includes("Bridge"), "超时要告诉用户去检查电脑端后台服务,而不是只给错误码");
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("POST /test:带凭据 body = 先保存再测(面板「保存并连接」一次点完)", async () => {
|
|
233
|
+
const relayDir = await tmpRelay();
|
|
234
|
+
await withHost(relayDir, {}, async (base) => {
|
|
235
|
+
// 桥在这期间连上(模拟:请求发出后桥写状态)
|
|
236
|
+
const timer = setTimeout(() => {
|
|
237
|
+
writeStatus(relayDir, { enabled: true, connected: true, authenticated: false, lastError: "" }).catch(() => {});
|
|
238
|
+
}, 50);
|
|
239
|
+
const r = await postTest(base, { botId: "aibdemo9999", secret: "new-secret", targetChatId: "chat-7" });
|
|
240
|
+
clearTimeout(timer);
|
|
241
|
+
assert.equal(r.status, 200);
|
|
242
|
+
const cfg = await readCfg(relayDir);
|
|
243
|
+
assert.equal(cfg.wecom.botId, "aibdemo9999", "POST body 里的凭据必须先落盘(桥只读文件)");
|
|
244
|
+
assert.equal(cfg.wecom.secret, "new-secret");
|
|
245
|
+
assert.equal(cfg.wecom.targetChatId, "chat-7");
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
// ─────────────────── 会话可选绑定(2026-11-07,task-25) ───────────────────
|
|
250
|
+
//
|
|
251
|
+
// 契约(与桥约定死,字段名不要改):
|
|
252
|
+
// 状态文件 wecom.sessions[{id,title,updatedAt}] / activeSessionId / boundSessionId
|
|
253
|
+
// 配置 wecom.sessionId + wecom.sessionTitle(空 sessionId = 跟随当前活跃会话)
|
|
254
|
+
// 面板只读状态文件里的会话列表,不自己拉会话;GET 必须按**白名单**挑字段。
|
|
255
|
+
|
|
256
|
+
test("GET:透出会话白名单字段(sessions/activeSessionId/boundSessionId),且只挑 id/title/updatedAt", async () => {
|
|
257
|
+
const relayDir = await tmpRelay();
|
|
258
|
+
await withHost(relayDir, {}, async (base) => {
|
|
259
|
+
await writeStatus(relayDir, {
|
|
260
|
+
enabled: true, connected: true, authenticated: true, lastError: "",
|
|
261
|
+
sessions: [
|
|
262
|
+
{ id: "sess-1-abcdef123456", title: "修复登录问题", updatedAt: 111, internalToken: "LEAK-1" },
|
|
263
|
+
{ id: "sess-2-xyz", title: "部署到 NAS", updatedAt: 222 },
|
|
264
|
+
{ id: "", title: "坏数据", updatedAt: 333 },
|
|
265
|
+
],
|
|
266
|
+
activeSessionId: "sess-2-xyz",
|
|
267
|
+
activeSessionTitle: "部署到 NAS",
|
|
268
|
+
boundSessionId: "sess-1-abcdef123456",
|
|
269
|
+
secret: "LEAK-SECRET",
|
|
270
|
+
});
|
|
271
|
+
const r = await json(`${base}/dsh-remote/im-bots`);
|
|
272
|
+
const st = r.body.status;
|
|
273
|
+
assert.equal(st.activeSessionId, "sess-2-xyz");
|
|
274
|
+
assert.equal(st.boundSessionId, "sess-1-abcdef123456");
|
|
275
|
+
assert.equal(st.sessions.length, 2, "没有 id 的坏数据要过滤掉");
|
|
276
|
+
assert.deepEqual(st.sessions[0], { id: "sess-1-abcdef123456", title: "修复登录问题", updatedAt: 111 },
|
|
277
|
+
"★ 会话只挑白名单字段(internalToken 不得外泄)");
|
|
278
|
+
assert.ok(!JSON.stringify(r.body).includes("LEAK"), "★ 状态文件里的敏感字段绝不能被透出");
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("PUT:保存会话绑定写 sessionId + sessionTitle;传空 sessionId = 跟随活跃(同时清掉标题)", async () => {
|
|
283
|
+
const relayDir = await tmpRelay();
|
|
284
|
+
await withHost(relayDir, {}, async (base) => {
|
|
285
|
+
await put(base, { botId: "aibdemo1234", secret: "x" });
|
|
286
|
+
const r1 = await put(base, { sessionId: "sess-1-abcdef123456", sessionTitle: "修复登录问题" });
|
|
287
|
+
assert.equal(r1.status, 200);
|
|
288
|
+
assert.equal(r1.body.wecom.sessionId, "sess-1-abcdef123456", "GET/PUT 回显已绑定会话");
|
|
289
|
+
assert.equal(r1.body.wecom.sessionTitle, "修复登录问题");
|
|
290
|
+
const cfg1 = await readCfg(relayDir);
|
|
291
|
+
assert.equal(cfg1.wecom.sessionId, "sess-1-abcdef123456");
|
|
292
|
+
assert.equal(cfg1.wecom.sessionTitle, "修复登录问题");
|
|
293
|
+
|
|
294
|
+
// 选「跟随当前活跃会话」→ 空 sessionId + 空 title
|
|
295
|
+
const r2 = await put(base, { sessionId: "", sessionTitle: "残留标题" });
|
|
296
|
+
assert.equal(r2.status, 200);
|
|
297
|
+
const cfg2 = await readCfg(relayDir);
|
|
298
|
+
assert.equal(cfg2.wecom.sessionId, "", "空 sessionId = 跟随当前活跃会话");
|
|
299
|
+
assert.equal(cfg2.wecom.sessionTitle, "", "★ 清空绑定时必须同时清掉标题(否则 UI 会显示错的目标)");
|
|
300
|
+
assert.equal(cfg2.wecom.botId, "aibdemo1234", "其它凭据不受影响");
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("PUT:会话 ID 过长 → 400 invalid_session_id(不写半成品)", async () => {
|
|
305
|
+
const relayDir = await tmpRelay();
|
|
306
|
+
await withHost(relayDir, {}, async (base) => {
|
|
307
|
+
await put(base, { botId: "aibdemo1234", secret: "x" });
|
|
308
|
+
const r = await put(base, { sessionId: "s".repeat(201) });
|
|
309
|
+
assert.equal(r.status, 400);
|
|
310
|
+
assert.equal(r.body.error, "invalid_session_id");
|
|
311
|
+
const cfg = await readCfg(relayDir);
|
|
312
|
+
assert.equal(cfg.wecom.sessionId, "", "校验失败不得落盘");
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
// ─────────────────── 发送预览卡片(2026-11-07,task-25 尾) ───────────────────
|
|
317
|
+
//
|
|
318
|
+
// 契约:桥监听 im-bots.json 的 wecom.previewCardAt(ms;值变大桥就发一张预览审批卡),
|
|
319
|
+
// 处理完把 wecom.lastPreviewCardAt 写回状态文件。插件只负责"把时间戳写大"。
|
|
320
|
+
|
|
321
|
+
test("GET:白名单透出 status.wecom.lastPreviewCardAt(桥写的已处理时间戳)", async () => {
|
|
322
|
+
const relayDir = await tmpRelay();
|
|
323
|
+
await withHost(relayDir, {}, async (base) => {
|
|
324
|
+
await writeStatus(relayDir, { enabled: true, connected: true, authenticated: true, lastError: "", lastPreviewCardAt: 1_700_000_123_456 });
|
|
325
|
+
const r = await json(`${base}/dsh-remote/im-bots`);
|
|
326
|
+
assert.equal(r.body.status.lastPreviewCardAt, 1_700_000_123_456, "lastPreviewCardAt 必须在白名单里");
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("PUT previewCardAt:写数字时间戳,且**不丢** botId/secret/会话绑定/接收会话(覆盖写不许冲掉其它字段)", async () => {
|
|
331
|
+
const relayDir = await tmpRelay();
|
|
332
|
+
await withHost(relayDir, {}, async (base) => {
|
|
333
|
+
await put(base, { botId: "aibdemo1234", secret: "keep-me", targetChatId: "chat-9", sessionId: "sess-1", sessionTitle: "修复登录" });
|
|
334
|
+
const before = await readCfg(relayDir);
|
|
335
|
+
const r = await put(base, { previewCardAt: 1_700_000_999_000 });
|
|
336
|
+
assert.equal(r.status, 200);
|
|
337
|
+
const cfg = await readCfg(relayDir);
|
|
338
|
+
assert.equal(cfg.wecom.previewCardAt, 1_700_000_999_000, "★ previewCardAt 必须落盘(桥只认这个数变大)");
|
|
339
|
+
assert.equal(cfg.wecom.botId, before.wecom.botId, "Bot ID 不能被冲掉");
|
|
340
|
+
assert.equal(cfg.wecom.secret, "keep-me", "★ Secret 不能被冲掉(面板不回显,丢了用户都不知道)");
|
|
341
|
+
assert.equal(cfg.wecom.targetChatId, "chat-9", "接收会话 ID 不能被冲掉");
|
|
342
|
+
assert.equal(cfg.wecom.sessionId, "sess-1", "会话绑定不能被冲掉");
|
|
343
|
+
assert.equal(cfg.wecom.sessionTitle, "修复登录", "会话标题不能被冲掉");
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
test("PUT previewCardAt:非法值(非数字 / 0 / 负数)→ 400 invalid_preview_card_at,且不写半成品", async () => {
|
|
348
|
+
const relayDir = await tmpRelay();
|
|
349
|
+
await withHost(relayDir, {}, async (base) => {
|
|
350
|
+
await put(base, { botId: "aibdemo1234", secret: "x" });
|
|
351
|
+
for (const bad of ["not-a-number", 0, -5]) {
|
|
352
|
+
const r = await put(base, { previewCardAt: bad });
|
|
353
|
+
assert.equal(r.status, 400, "非法值必须 400:" + String(bad));
|
|
354
|
+
assert.equal(r.body.error, "invalid_preview_card_at");
|
|
355
|
+
}
|
|
356
|
+
const cfg = await readCfg(relayDir);
|
|
357
|
+
assert.equal(cfg.wecom.previewCardAt, 0, "非法触发值绝不能写进文件(桥会永久失效且难排查)");
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
// ─────────────────── 会员档位 / 额度(2026-11-07 桥侧判定) ───────────────────
|
|
362
|
+
|
|
363
|
+
test("GET:白名单透出 tier/caps/capsCount/bindingGated/messagesPerMonth/remainingMessages/usage", async () => {
|
|
364
|
+
const relayDir = await tmpRelay();
|
|
365
|
+
await withHost(relayDir, {}, async (base) => {
|
|
366
|
+
await writeStatus(relayDir, {
|
|
367
|
+
enabled: true, connected: true, tier: "free",
|
|
368
|
+
caps: ["wecom", "session_binding"], capsCount: 2, bindingGated: true,
|
|
369
|
+
messagesPerMonth: 20, remainingMessages: 7,
|
|
370
|
+
usage: { month: "2026-11", messages: 13 },
|
|
371
|
+
// 攻击面:状态文件里的内部字段/额外字段绝不能被整包吐出去
|
|
372
|
+
secret: "LEAK", capsRaw: { internal: "LEAK2" }, extra: "LEAK3",
|
|
373
|
+
});
|
|
374
|
+
const r = await json(`${base}/dsh-remote/im-bots`);
|
|
375
|
+
const st = r.body.status;
|
|
376
|
+
assert.equal(st.tier, "free");
|
|
377
|
+
assert.deepEqual(st.caps, ["wecom", "session_binding"], "caps 只取字符串数组(截断/过滤),不得透出对象");
|
|
378
|
+
assert.equal(st.capsCount, 2);
|
|
379
|
+
assert.equal(st.bindingGated, true);
|
|
380
|
+
assert.equal(st.messagesPerMonth, 20);
|
|
381
|
+
assert.equal(st.remainingMessages, 7);
|
|
382
|
+
assert.deepEqual(st.usage, { month: "2026-11", messages: 13 });
|
|
383
|
+
assert.ok(!JSON.stringify(r.body).includes("LEAK"), "★ 白名单之外的状态字段绝不能被透出");
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test("GET:tier 只认三个枚举值;remainingMessages=null 表示不限/未知(不能被当成 0)", async () => {
|
|
388
|
+
const relayDir = await tmpRelay();
|
|
389
|
+
await withHost(relayDir, {}, async (base) => {
|
|
390
|
+
await writeStatus(relayDir, { tier: "gold", remainingMessages: null, messagesPerMonth: 0 });
|
|
391
|
+
let st = (await json(`${base}/dsh-remote/im-bots`)).body.status;
|
|
392
|
+
assert.equal(st.tier, "", "未知档位必须归一成空串(面板据此不渲染,绝不猜)");
|
|
393
|
+
assert.equal(st.remainingMessages, null, "null 必须保持 null(0 条剩余 ≠ 不限量)");
|
|
394
|
+
assert.equal(st.messagesPerMonth, 0);
|
|
395
|
+
|
|
396
|
+
await writeStatus(relayDir, { tier: "pro_max", remainingMessages: null });
|
|
397
|
+
st = (await json(`${base}/dsh-remote/im-bots`)).body.status;
|
|
398
|
+
assert.equal(st.tier, "pro_max");
|
|
399
|
+
});
|
|
400
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 面板 IA(2026-11-07 重做)在**测试脚手架**里的公共导航工具。
|
|
3
|
+
*
|
|
4
|
+
* 背景:重做后主面板 = 状态头 + 入口网格 + 弹层。旧用例大量直接断言「卡片里的内容」,
|
|
5
|
+
* 而这些内容现在都在弹层里(打开后才渲染)。与其在每个用例里复制一段"点开弹层"的代码,
|
|
6
|
+
* 不如把"按真实用户路径打开弹层"收敛成这里的小工具:
|
|
7
|
+
* · openEntry(tree, kind) 点入口卡上的按钮(mobile / channel / devices / account);
|
|
8
|
+
* · clickChannelRow(tree, t) 在通道弹层里点某一行(如「微信机器人(扫码绑定)」);
|
|
9
|
+
* · textOf(node) 节点子树里的全部文本(拼接)。
|
|
10
|
+
* 这些函数只读 fake tree({type,props,children}),不依赖具体测试文件的 react stub。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** 先序遍历。 */
|
|
14
|
+
export function walk(node, visit) {
|
|
15
|
+
if (node == null) return;
|
|
16
|
+
if (Array.isArray(node)) { for (const item of node) walk(item, visit); return; }
|
|
17
|
+
if (typeof node !== "object") return;
|
|
18
|
+
visit(node);
|
|
19
|
+
for (const child of node.children || []) walk(child, visit);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function find(tree, predicate) {
|
|
23
|
+
let match;
|
|
24
|
+
walk(tree, (node) => { if (!match && predicate(node)) match = node; });
|
|
25
|
+
return match;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function findAll(tree, predicate) {
|
|
29
|
+
const out = [];
|
|
30
|
+
walk(tree, (node) => { if (predicate(node)) out.push(node); });
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 节点子树里的全部文本(拼接;用于"卡片标题/按钮文案"这类断言)。 */
|
|
35
|
+
export function textOf(node) {
|
|
36
|
+
let out = "";
|
|
37
|
+
walk(node, (n) => {
|
|
38
|
+
for (const c of n.children || []) if (typeof c === "string") out += c;
|
|
39
|
+
});
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 卡的标题文本(入口网格里的那张卡)。 */
|
|
44
|
+
function cardTitle(cardEl) {
|
|
45
|
+
const t = find(cardEl, (n) => String(n.props && n.props.className || "").split(/\s+/).includes("dru-ia-card-title"));
|
|
46
|
+
return t ? textOf(t) : "";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 入口卡的按钮(.dru-ia-card-actions 里的第一个 button)。 */
|
|
50
|
+
function cardButton(cardEl) {
|
|
51
|
+
return find(cardEl, (n) => n.type === "button");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const ENTRY_TITLE = {
|
|
55
|
+
mobile: "手机访问",
|
|
56
|
+
channel: "通知与消息通道",
|
|
57
|
+
devices: "已授权设备",
|
|
58
|
+
account: "账号",
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 按真实用户路径点开某个入口弹层。
|
|
63
|
+
* @returns {boolean} 是否点到了(false = 结构没找到,用例应显式失败而不是静默通过)
|
|
64
|
+
*/
|
|
65
|
+
export function openEntry(tree, kind) {
|
|
66
|
+
const title = ENTRY_TITLE[kind];
|
|
67
|
+
if (!title) return false;
|
|
68
|
+
const card = find(tree, (n) => {
|
|
69
|
+
const cls = String((n.props && n.props.className) || "");
|
|
70
|
+
if (!cls.split(/\s+/).includes("dru-ia-card")) return false;
|
|
71
|
+
return cardTitle(n) === title;
|
|
72
|
+
});
|
|
73
|
+
if (!card) return false;
|
|
74
|
+
const btn = cardButton(card);
|
|
75
|
+
if (!btn || typeof btn.props?.onClick !== "function") return false;
|
|
76
|
+
btn.props.onClick();
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 在通道弹层的平台列表里点某一行(按标题匹配,如「微信机器人(扫码绑定)」)。 */
|
|
81
|
+
export function clickChannelRow(tree, titleIncludes) {
|
|
82
|
+
const row = find(tree, (n) => {
|
|
83
|
+
const cls = String((n.props && n.props.className) || "");
|
|
84
|
+
if (!cls.split(/\s+/).includes("dru-ia-row")) return false;
|
|
85
|
+
const t = find(n, (x) => String((x.props && x.props.className) || "").split(/\s+/).includes("dru-ia-row-title"));
|
|
86
|
+
return !!t && textOf(t).includes(titleIncludes);
|
|
87
|
+
});
|
|
88
|
+
if (!row || typeof row.props?.onClick !== "function") return false;
|
|
89
|
+
row.props.onClick();
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 弹层是否在树里(role=dialog + .dru-ia-dialog)。 */
|
|
94
|
+
export function findDialog(tree) {
|
|
95
|
+
return find(tree, (n) => String((n.props && n.props.className) || "").split(/\s+/).includes("dru-ia-dialog"));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function dialogIsOpen(tree) { return !!findDialog(tree); }
|