@mrrisega/dsh-remote 0.7.11-beta.3 → 0.7.12-beta.2
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/dingtalk-bot.mjs +1318 -0
- package/clients/dsh-remote/docs/feishu-bot-contract.md +177 -0
- package/clients/dsh-remote/dsh-bridge.mjs +398 -80
- package/clients/dsh-remote/feishu-bot.mjs +1668 -0
- package/clients/dsh-remote/im-format.mjs +372 -0
- package/clients/dsh-remote/mirror-diag-script.js +53 -14
- package/clients/dsh-remote/mobile-adapter.mjs +355 -40
- package/clients/dsh-remote/test/bridge-http-abort.test.mjs +143 -0
- package/clients/dsh-remote/test/bridge-slow-response.test.mjs +107 -0
- package/clients/dsh-remote/test/bridge-tunnel-receiver.test.mjs +135 -0
- package/clients/dsh-remote/test/dingtalk-bot.test.mjs +550 -0
- package/clients/dsh-remote/test/feishu-bot.test.mjs +839 -0
- package/clients/dsh-remote/test/im-format.test.mjs +229 -0
- package/clients/dsh-remote/test/mirror-diag.test.mjs +47 -2
- package/clients/dsh-remote/test/mobile-adapter-load-watch.test.mjs +208 -9
- package/clients/dsh-remote/test/upstream-discovery.test.mjs +203 -2
- package/clients/dsh-remote/test/wecom-bot.test.mjs +281 -1
- package/clients/dsh-remote/upstream-discovery.mjs +495 -43
- package/clients/dsh-remote/wecom-bot.mjs +238 -12
- package/dsh-setup.mjs +78 -7
- package/package.json +2 -2
- package/packages/dsh-remote-web/lib/client.js +703 -115
- package/packages/dsh-remote-web/lib/index.js +487 -31
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +7 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/im-format.mjs +372 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/upstream-discovery.mjs +495 -43
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wecom-bot.mjs +238 -12
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +94 -8
- package/packages/dsh-remote-web/test/im-bots.test.mjs +265 -2
- package/packages/dsh-remote-web/test/login-fast.test.mjs +104 -0
- package/packages/dsh-remote-web/test/login-selfheal-ui.test.mjs +4 -1
- package/packages/dsh-remote-web/test/panel-ia.test.mjs +397 -34
- package/packages/dsh-remote-web/test/purchase-agreement-ui.test.mjs +4 -4
- package/packages/dsh-remote-web/test/remote-access-ui.test.mjs +51 -113
- package/packages/dsh-remote-web/test/self-manage.test.mjs +5 -1
- package/packages/dsh-remote-web/test/settings-entry.test.mjs +13 -5
- package/packages/dsh-remote-web/test/telemetry.test.mjs +2 -2
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// 暴露给任何能打开本机 dsh web 页面的人(哪怕它只是一台共享电脑)。
|
|
13
13
|
import assert from "node:assert/strict";
|
|
14
14
|
import http from "node:http";
|
|
15
|
-
import { mkdtemp, readFile, stat, writeFile } from "node:fs/promises";
|
|
15
|
+
import { mkdir, mkdtemp, readFile, stat, writeFile } from "node:fs/promises";
|
|
16
16
|
import os from "node:os";
|
|
17
17
|
import path from "node:path";
|
|
18
18
|
import test from "node:test";
|
|
@@ -199,7 +199,7 @@ test("POST /test:桥写的状态显示已连接 → {ok:true, connected:true,
|
|
|
199
199
|
await writeStatus(relayDir, { enabled: true, connected: true, authenticated: true, lastError: "" });
|
|
200
200
|
const r = await postTest(base); // 不传 body = 测已保存的配置
|
|
201
201
|
assert.equal(r.status, 200);
|
|
202
|
-
assert.deepEqual(r.body, { ok: true, connected: true, authenticated: true, lastError: "" });
|
|
202
|
+
assert.deepEqual(r.body, { ok: true, platform: "wecom", connected: true, authenticated: true, lastError: "" });
|
|
203
203
|
});
|
|
204
204
|
});
|
|
205
205
|
|
|
@@ -357,3 +357,266 @@ test("PUT previewCardAt:非法值(非数字 / 0 / 负数)→ 400 invalid_p
|
|
|
357
357
|
assert.equal(cfg.wecom.previewCardAt, 0, "非法触发值绝不能写进文件(桥会永久失效且难排查)");
|
|
358
358
|
});
|
|
359
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
|
+
});
|
|
401
|
+
|
|
402
|
+
// ─────────────────── 钉钉 / 飞书通道(2026-11-08) ───────────────────
|
|
403
|
+
|
|
404
|
+
const putPlatform = (base, platform, config) => json(`${base}/dsh-remote/im-bots`, {
|
|
405
|
+
method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ platform, config }),
|
|
406
|
+
});
|
|
407
|
+
const postPlatformTest = (base, platform, config) => json(`${base}/dsh-remote/im-bots/test`, {
|
|
408
|
+
method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(config ? { platform, config } : { platform }),
|
|
409
|
+
});
|
|
410
|
+
const getDeps = async (base) => (await json(`${base}/dsh-remote/im-deps`)).body;
|
|
411
|
+
const post = (base, url) => json(url, { method: "POST", headers: { "content-type": "application/json" }, body: "{}" });
|
|
412
|
+
|
|
413
|
+
test("GET:dingtalk / feishu 两段都只回脱敏视图(有 hasSecret,绝无 clientSecret/appSecret 值)", async () => {
|
|
414
|
+
const relayDir = await tmpRelay();
|
|
415
|
+
await withHost(relayDir, {}, async (base) => {
|
|
416
|
+
await putPlatform(base, "dingtalk", { clientId: "ding-id", clientSecret: "DING-SECRET", robotCode: "ding-robot" });
|
|
417
|
+
await putPlatform(base, "feishu", { appId: "cli_1", appSecret: "FS-SECRET", receiveId: "ou_1", receiveIdType: "open_id" });
|
|
418
|
+
const r = await json(`${base}/dsh-remote/im-bots`);
|
|
419
|
+
assert.equal(r.body.dingtalk.clientId, "ding-id");
|
|
420
|
+
assert.equal(r.body.dingtalk.robotCode, "ding-robot");
|
|
421
|
+
assert.equal(r.body.dingtalk.hasSecret, true);
|
|
422
|
+
assert.equal(r.body.feishu.appId, "cli_1");
|
|
423
|
+
assert.equal(r.body.feishu.receiveIdType, "open_id");
|
|
424
|
+
assert.equal(r.body.feishu.hasSecret, true);
|
|
425
|
+
const raw = JSON.stringify(r.body);
|
|
426
|
+
assert.ok(!raw.includes("DING-SECRET"), "★ 钉钉 secret 绝不能出现");
|
|
427
|
+
assert.ok(!raw.includes("FS-SECRET"), "★ 飞书 secret 绝不能出现");
|
|
428
|
+
assert.ok(!raw.includes("clientSecret") && !raw.includes("appSecret"), "连字段名都不出现");
|
|
429
|
+
});
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test("PUT:保存钉钉配置(0600 原子写 + 保留未知字段 + 空 secret 不覆盖);缺字段给人话错误", async () => {
|
|
433
|
+
const relayDir = await tmpRelay();
|
|
434
|
+
await withHost(relayDir, {}, async (base) => {
|
|
435
|
+
// 先写一份带未知字段的旧文件:插件不认识,但不能删掉
|
|
436
|
+
await writeFile(path.join(relayDir, "im-bots.json"), JSON.stringify({ version: 1, future: { keep: 1 } }));
|
|
437
|
+
let r = await putPlatform(base, "dingtalk", { clientId: "id1", clientSecret: "sec1", robotCode: "rc1" });
|
|
438
|
+
assert.equal(r.status, 200);
|
|
439
|
+
let cfg = JSON.parse(await readFile(path.join(relayDir, "im-bots.json"), "utf8"));
|
|
440
|
+
assert.equal(cfg.dingtalk.clientId, "id1");
|
|
441
|
+
assert.equal(cfg.dingtalk.clientSecret, "sec1");
|
|
442
|
+
assert.equal(cfg.dingtalk.robotCode, "rc1");
|
|
443
|
+
assert.equal(cfg.dingtalk.enabled, true, "首次保存凭据 = 想让它生效");
|
|
444
|
+
assert.deepEqual(cfg.future, { keep: 1 }, "未知字段必须保留");
|
|
445
|
+
const mode = (await stat(path.join(relayDir, "im-bots.json"))).mode & 0o777;
|
|
446
|
+
assert.equal(mode, 0o600, "配置文件必须 0600");
|
|
447
|
+
|
|
448
|
+
// 空 secret = 不修改
|
|
449
|
+
r = await putPlatform(base, "dingtalk", { clientId: "id2", clientSecret: "", robotCode: "rc2" });
|
|
450
|
+
assert.equal(r.status, 200);
|
|
451
|
+
cfg = JSON.parse(await readFile(path.join(relayDir, "im-bots.json"), "utf8"));
|
|
452
|
+
assert.equal(cfg.dingtalk.clientId, "id2");
|
|
453
|
+
assert.equal(cfg.dingtalk.clientSecret, "sec1", "空 secret 表示不修改");
|
|
454
|
+
assert.equal(cfg.dingtalk.robotCode, "rc2");
|
|
455
|
+
|
|
456
|
+
// 缺字段
|
|
457
|
+
r = await putPlatform(base, "dingtalk", { clientId: "id3", clientSecret: "sec3", robotCode: "" });
|
|
458
|
+
assert.equal(r.status, 400);
|
|
459
|
+
assert.equal(r.body.error, "missing_robot_code", "三个字段都要:缺 Robot Code 要报出来");
|
|
460
|
+
});
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
test("PUT:保存飞书配置(字段锁死 + receiveIdType 校验 + 空 secret 不覆盖)", async () => {
|
|
464
|
+
const relayDir = await tmpRelay();
|
|
465
|
+
await withHost(relayDir, {}, async (base) => {
|
|
466
|
+
let r = await putPlatform(base, "feishu", { appId: "cli_a", appSecret: "fs_a", receiveId: "ou_a", receiveIdType: "chat_id" });
|
|
467
|
+
assert.equal(r.status, 200);
|
|
468
|
+
let cfg = JSON.parse(await readFile(path.join(relayDir, "im-bots.json"), "utf8"));
|
|
469
|
+
assert.equal(cfg.feishu.receiveIdType, "chat_id");
|
|
470
|
+
assert.equal(cfg.feishu.domain, "https://open.feishu.cn", "domain 默认官方");
|
|
471
|
+
r = await putPlatform(base, "feishu", { appId: "cli_b", appSecret: "", receiveId: "ou_b" });
|
|
472
|
+
cfg = JSON.parse(await readFile(path.join(relayDir, "im-bots.json"), "utf8"));
|
|
473
|
+
assert.equal(cfg.feishu.appSecret, "fs_a", "空 secret 不覆盖");
|
|
474
|
+
assert.equal(cfg.feishu.receiveIdType, "chat_id", "未传的字段 = 不修改(保留上次的 chat_id)");
|
|
475
|
+
// 非法 receiveIdType
|
|
476
|
+
r = await putPlatform(base, "feishu", { appId: "x", appSecret: "y", receiveId: "z", receiveIdType: "userid" });
|
|
477
|
+
assert.equal(r.status, 400);
|
|
478
|
+
assert.equal(r.body.error, "invalid_receive_id_type");
|
|
479
|
+
});
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
test("POST /test:platform=dingtalk / feishu 各自轮询对应状态(不再混用 wecom 状态)", async () => {
|
|
483
|
+
const relayDir = await tmpRelay();
|
|
484
|
+
await withHost(relayDir, {}, async (base) => {
|
|
485
|
+
await putPlatform(base, "dingtalk", { clientId: "i", clientSecret: "s", robotCode: "r" });
|
|
486
|
+
await putPlatform(base, "feishu", { appId: "cli_i", appSecret: "fs_s", receiveId: "ou_i" });
|
|
487
|
+
await writeStatus(relayDir, { connected: false, authenticated: false, lastError: "" }, {
|
|
488
|
+
dingtalk: { enabled: true, connected: true, authenticated: true, lastError: "" },
|
|
489
|
+
feishu: { enabled: true, connected: true, authenticated: true, lastError: "" },
|
|
490
|
+
});
|
|
491
|
+
let r = await postPlatformTest(base, "dingtalk");
|
|
492
|
+
assert.equal(r.status, 200);
|
|
493
|
+
assert.equal(r.body.platform, "dingtalk");
|
|
494
|
+
assert.equal(r.body.connected, true);
|
|
495
|
+
r = await postPlatformTest(base, "feishu");
|
|
496
|
+
assert.equal(r.body.platform, "feishu");
|
|
497
|
+
assert.equal(r.body.connected, true);
|
|
498
|
+
// 未保存 secret 的平台:给明确错误,而不是"连不上"
|
|
499
|
+
await withHost(await tmpRelay(), {}, async (base2) => {
|
|
500
|
+
const rr = await postPlatformTest(base2, "dingtalk");
|
|
501
|
+
assert.equal(rr.status, 400);
|
|
502
|
+
assert.equal(rr.body.error, "missing_secret");
|
|
503
|
+
});
|
|
504
|
+
});
|
|
505
|
+
});
|
|
506
|
+
|
|
507
|
+
test("GET 状态白名单:status.dingtalk / status.feishu 按契约透出(含 tier/caps/usage/state),状态文件敏感字段不外泄", async () => {
|
|
508
|
+
const relayDir = await tmpRelay();
|
|
509
|
+
await withHost(relayDir, {}, async (base) => {
|
|
510
|
+
await writeStatus(relayDir, { connected: false }, {
|
|
511
|
+
dingtalk: {
|
|
512
|
+
enabled: true, state: "connected", connected: true, authenticated: true, lastError: "", boundChatId: "chat-9",
|
|
513
|
+
tier: "pro", caps: ["dingtalk"], capsCount: 1, bindingGated: false, messagesPerMonth: 0, remainingMessages: null,
|
|
514
|
+
usage: { month: "2026-11", messages: 5 }, clientSecret: "LEAK-D", token: "LEAK-T",
|
|
515
|
+
},
|
|
516
|
+
feishu: {
|
|
517
|
+
enabled: true, state: "failed", connected: false, authenticated: false, lastError: "鉴权失败", boundChatId: "",
|
|
518
|
+
tier: "free", caps: [], capsCount: 0, bindingGated: true, messagesPerMonth: 20, remainingMessages: 19,
|
|
519
|
+
usage: { month: "2026-11", messages: 1 }, appSecret: "LEAK-F",
|
|
520
|
+
},
|
|
521
|
+
});
|
|
522
|
+
const r = await json(`${base}/dsh-remote/im-bots`);
|
|
523
|
+
const d = r.body.status.dingtalk;
|
|
524
|
+
assert.equal(d.state, "connected");
|
|
525
|
+
assert.equal(d.tier, "pro");
|
|
526
|
+
assert.deepEqual(d.caps, ["dingtalk"]);
|
|
527
|
+
assert.equal(d.remainingMessages, null, "null 必须保持 null(≠0)");
|
|
528
|
+
assert.deepEqual(d.usage, { month: "2026-11", messages: 5 });
|
|
529
|
+
const f = r.body.status.feishu;
|
|
530
|
+
assert.equal(f.state, "failed");
|
|
531
|
+
assert.equal(f.tier, "free");
|
|
532
|
+
assert.equal(f.bindingGated, true);
|
|
533
|
+
assert.equal(f.remainingMessages, 19);
|
|
534
|
+
assert.ok(!JSON.stringify(r.body).includes("LEAK"), "★ 桥写状态里的敏感字段必须被白名单挡掉");
|
|
535
|
+
// wecom 老字段仍在(向后兼容)
|
|
536
|
+
assert.equal(typeof r.body.status.connected, "boolean");
|
|
537
|
+
});
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
// 假子进程:测试注入 opts.imDepsSpawn,绝不真的联网 npm install
|
|
541
|
+
function fakeSpawnChild(installed, stderrText) {
|
|
542
|
+
const handlers = {};
|
|
543
|
+
return {
|
|
544
|
+
stdout: { on() {} },
|
|
545
|
+
stderr: { on(_e, cb) { handlers.data = cb; } },
|
|
546
|
+
on(ev, cb) { handlers[ev] = cb; },
|
|
547
|
+
_finish() {
|
|
548
|
+
if (installed) handlers.installed = true;
|
|
549
|
+
setTimeout(() => {
|
|
550
|
+
if (stderrText && handlers.data) handlers.data(Buffer.from(stderrText));
|
|
551
|
+
if (handlers.exit) handlers.exit(installed ? 0 : 1);
|
|
552
|
+
}, 20);
|
|
553
|
+
}
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
test("GET /im-deps + POST /im-deps/feishu:后台安装(注入假 spawn,不联网),phase 从 downloading→done", async () => {
|
|
558
|
+
const relayDir = await tmpRelay();
|
|
559
|
+
const pkgDir = path.join(relayDir, "im-deps", "node_modules", "@larksuiteoapi", "node-sdk");
|
|
560
|
+
const spawnCalls = [];
|
|
561
|
+
const imDepsSpawn = (cmd, args) => {
|
|
562
|
+
spawnCalls.push({ cmd, args });
|
|
563
|
+
const child = fakeSpawnChild(true, "");
|
|
564
|
+
// 模拟 npm 装完后落盘(测试里直接写好 package.json)
|
|
565
|
+
setTimeout(async () => {
|
|
566
|
+
await mkdir(pkgDir, { recursive: true });
|
|
567
|
+
await writeFile(path.join(pkgDir, "package.json"), JSON.stringify({ name: "@larksuiteoapi/node-sdk", version: "1.40.0" }));
|
|
568
|
+
child._finish();
|
|
569
|
+
}, 10);
|
|
570
|
+
return child;
|
|
571
|
+
};
|
|
572
|
+
await withHost(relayDir, { imDepsSpawn }, async (base) => {
|
|
573
|
+
let deps = await getDeps(base);
|
|
574
|
+
assert.equal(deps.feishu.installed, false);
|
|
575
|
+
assert.equal(deps.feishu.phase, "idle");
|
|
576
|
+
|
|
577
|
+
const r = await post(base, `${base}/dsh-remote/im-deps/feishu`);
|
|
578
|
+
assert.equal(r.status, 200);
|
|
579
|
+
assert.equal(r.body.ok, true);
|
|
580
|
+
assert.equal(r.body.started, true);
|
|
581
|
+
assert.ok(spawnCalls.length === 1, "应触发一次 npm install");
|
|
582
|
+
assert.ok(spawnCalls[0].args.join(" ").includes("@larksuiteoapi/node-sdk@1.40.0"), "安装命令必须钉死 SDK 版本");
|
|
583
|
+
|
|
584
|
+
// 轮询 phase 直到 done
|
|
585
|
+
let phase = "";
|
|
586
|
+
for (let i = 0; i < 50; i++) {
|
|
587
|
+
deps = await getDeps(base);
|
|
588
|
+
phase = deps.feishu.phase;
|
|
589
|
+
if (phase === "done" || phase === "failed") break;
|
|
590
|
+
await new Promise((r2) => setTimeout(r2, 30));
|
|
591
|
+
}
|
|
592
|
+
deps = await getDeps(base);
|
|
593
|
+
assert.equal(deps.feishu.phase, "done", "安装完成后 phase=done,实际 " + deps.feishu.phase);
|
|
594
|
+
assert.equal(deps.feishu.installed, true);
|
|
595
|
+
assert.equal(deps.feishu.version, "1.40.0");
|
|
596
|
+
assert.ok(deps.feishu.path.includes("node-sdk"));
|
|
597
|
+
});
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
test("POST /im-deps/feishu:安装失败 → phase=failed + 人话原因(network/disk/permission/sdk_install_failed)", async () => {
|
|
601
|
+
const relayDir = await tmpRelay();
|
|
602
|
+
const imDepsSpawn = () => {
|
|
603
|
+
const child = fakeSpawnChild(false, "npm ERR! code ETIMEDOUT network registry.npmjs.org");
|
|
604
|
+
setTimeout(() => child._finish(), 10);
|
|
605
|
+
return child;
|
|
606
|
+
};
|
|
607
|
+
await withHost(relayDir, { imDepsSpawn }, async (base) => {
|
|
608
|
+
const r = await post(base, `${base}/dsh-remote/im-deps/feishu`);
|
|
609
|
+
assert.equal(r.status, 200);
|
|
610
|
+
let deps = null;
|
|
611
|
+
for (let i = 0; i < 60; i++) {
|
|
612
|
+
deps = await getDeps(base);
|
|
613
|
+
if (deps.feishu.phase === "failed") break;
|
|
614
|
+
await new Promise((r2) => setTimeout(r2, 30));
|
|
615
|
+
}
|
|
616
|
+
assert.equal(deps.feishu.phase, "failed", "失败必须落到 failed 而不是永远 downloading");
|
|
617
|
+
assert.match(deps.feishu.lastError, /网络不通/, "网络类错误要翻成人话");
|
|
618
|
+
// 已失败后重试:再次触发安装
|
|
619
|
+
const r2 = await post(base, `${base}/dsh-remote/im-deps/feishu`);
|
|
620
|
+
assert.equal(r2.body.started, true, "失败后允许重试");
|
|
621
|
+
});
|
|
622
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// 登录/首屏不再阻塞在「本机端口发现」上(2026-11-07 业主反馈:登录 7~8 秒太慢)。
|
|
2
|
+
//
|
|
3
|
+
// 根因定位(Lead):登录后前端拉 /bridge-status 与 /account,服务端 composeConnect 里
|
|
4
|
+
// `await discoverUpstreamSelf()`(lsof/netstat + 数百端口探测)+ launchctl/ps 全在响应路径上;
|
|
5
|
+
// /config 还在响应里 `...(await composeStatus())`。
|
|
6
|
+
//
|
|
7
|
+
// 本文件锁三件事:
|
|
8
|
+
// ① composeConnect **不 await** 慢发现(<300ms 返回,并标记 discoveryPending);
|
|
9
|
+
// ② POST /dsh-remote/config 立即返回(不带衍生状态,statusPending=true);
|
|
10
|
+
// ③ shell 探测(launchctl/ps)走 10s TTL 缓存(避免每次轮询重跑)。
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import http from "node:http";
|
|
13
|
+
import { mkdtemp } from "node:fs/promises";
|
|
14
|
+
import os from "node:os";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import test from "node:test";
|
|
17
|
+
import {
|
|
18
|
+
apply,
|
|
19
|
+
__composeConnect as composeConnect,
|
|
20
|
+
__setUpstreamTestState as setUpstreamTestState,
|
|
21
|
+
__resetUpstreamTestState as resetUpstreamTestState,
|
|
22
|
+
__clearStatusProbeCache as clearStatusProbeCache,
|
|
23
|
+
__STATUS_PROBE_TTL_MS as STATUS_PROBE_TTL_MS,
|
|
24
|
+
} from "../lib/index.js";
|
|
25
|
+
|
|
26
|
+
// unref:模拟"慢发现"的定时器不能把测试进程空等到 5 秒(本套件对时长敏感)。
|
|
27
|
+
const sleep = (ms) => new Promise((r) => { const t = setTimeout(r, ms); try { t.unref?.(); } catch { /* 忽略 */ } });
|
|
28
|
+
const tmpRelay = () => mkdtemp(path.join(os.tmpdir(), "dsh-login-fast-"));
|
|
29
|
+
|
|
30
|
+
test("composeConnect:端口发现再慢(5s)也不阻塞状态组装(<300ms),并标记 discoveryPending", async () => {
|
|
31
|
+
const relayDir = await tmpRelay();
|
|
32
|
+
setUpstreamTestState({ unsettled: true, discover: async () => { await sleep(5000); return "http://127.0.0.1:43210"; } });
|
|
33
|
+
clearStatusProbeCache();
|
|
34
|
+
try {
|
|
35
|
+
const t0 = Date.now();
|
|
36
|
+
const conn = await composeConnect(relayDir);
|
|
37
|
+
const cost = Date.now() - t0;
|
|
38
|
+
assert.ok(cost < 300, "★ composeConnect 必须 <300ms 返回(实测 " + cost + "ms)");
|
|
39
|
+
assert.equal(conn.discoveryPending, true, "端口发现还没完成时,必须如实标记 discoveryPending(面板可显示骨架)");
|
|
40
|
+
assert.ok(conn.phase, "状态阶段照常返回(不因发现未完成而缺字段)");
|
|
41
|
+
} finally {
|
|
42
|
+
resetUpstreamTestState();
|
|
43
|
+
clearStatusProbeCache();
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("composeConnect:发现已完成(缓存/hint)→ 不再触发发现、discoveryPending=false", async () => {
|
|
48
|
+
const relayDir = await tmpRelay();
|
|
49
|
+
setUpstreamTestState({ unsettled: false, discover: async () => { throw new Error("不该被调用"); } });
|
|
50
|
+
clearStatusProbeCache();
|
|
51
|
+
try {
|
|
52
|
+
const conn = await composeConnect(relayDir);
|
|
53
|
+
assert.equal(conn.discoveryPending, false, "发现已 settled 时不应标记 pending");
|
|
54
|
+
} finally {
|
|
55
|
+
resetUpstreamTestState();
|
|
56
|
+
clearStatusProbeCache();
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("POST /dsh-remote/config:立即返回(不 await 衍生状态),body.statusPending=true 且只回最小 config", async () => {
|
|
61
|
+
const relayDir = await tmpRelay();
|
|
62
|
+
setUpstreamTestState({ unsettled: true, discover: async () => { await sleep(5000); return "http://127.0.0.1:43210"; } });
|
|
63
|
+
const routes = [];
|
|
64
|
+
apply({
|
|
65
|
+
webServer: { register(rt) { routes.push(rt); return () => {}; } },
|
|
66
|
+
effect(reg) { return reg(); },
|
|
67
|
+
logger: { info() {}, warn() {} },
|
|
68
|
+
}, { relayDir });
|
|
69
|
+
const host = http.createServer((req, res) => {
|
|
70
|
+
const url = new URL(req.url, "http://x");
|
|
71
|
+
const rt = routes.find((r) => (r.kind === "prefix"
|
|
72
|
+
? url.pathname === r.path || url.pathname.startsWith(r.path + "/")
|
|
73
|
+
: url.pathname === r.path));
|
|
74
|
+
(rt ? rt.handler : ((_r, rs) => { rs.writeHead(404); rs.end(); }))(req, res);
|
|
75
|
+
});
|
|
76
|
+
await new Promise((r) => host.listen(0, "127.0.0.1", r));
|
|
77
|
+
const base = `http://127.0.0.1:${host.address().port}`;
|
|
78
|
+
try {
|
|
79
|
+
const t0 = Date.now();
|
|
80
|
+
const r = await fetch(`${base}/dsh-remote/config`, {
|
|
81
|
+
method: "POST",
|
|
82
|
+
headers: { "content-type": "application/json" },
|
|
83
|
+
body: JSON.stringify({ phone: "13800000000", password: "password123" }),
|
|
84
|
+
});
|
|
85
|
+
const cost = Date.now() - t0;
|
|
86
|
+
const body = await r.json();
|
|
87
|
+
assert.equal(r.status, 200);
|
|
88
|
+
assert.ok(cost < 300, "★ /config 必须 <300ms(实测 " + cost + "ms)—— 登录按钮的等待只该是登录请求本身");
|
|
89
|
+
assert.equal(body.ok, true);
|
|
90
|
+
assert.equal(body.statusPending, true, "★ 立即返回时必须声明 statusPending(前端据此异步补状态)");
|
|
91
|
+
assert.equal(typeof body.config, "object", "回最小配置回显(供 UI 立刻切换已登录态)");
|
|
92
|
+
assert.equal(body.config.hasPhone, true);
|
|
93
|
+
assert.equal("connect" in body, false, "★ 响应里不得再带衍生状态(connect/隧道/端口探测结果)");
|
|
94
|
+
assert.equal("service" in body, false, "★ 响应里不得再带 service 衍生信息");
|
|
95
|
+
} finally {
|
|
96
|
+
resetUpstreamTestState();
|
|
97
|
+
clearStatusProbeCache();
|
|
98
|
+
await new Promise((r) => host.close(r));
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("shell 探测 TTL:STATUS_PROBE_TTL_MS 是 10s 级短缓存(不是每次轮询都重跑 launchctl/ps)", () => {
|
|
103
|
+
assert.equal(STATUS_PROBE_TTL_MS, 10 * 1000, "缓存 TTL 必须是 10s(B1 约定)");
|
|
104
|
+
});
|
|
@@ -227,7 +227,10 @@ test("未登录:不请求企业端、不出误导性红字;登录后自动
|
|
|
227
227
|
|
|
228
228
|
assert.ok(!plugin.requests.some((r) => r.path === "/dsh-remote/access-key"), "未登录不得请求二维码接口");
|
|
229
229
|
assert.ok(!plugin.requests.some((r) => r.path === "/dsh-remote/mobile-sessions"), "未登录不得请求设备列表");
|
|
230
|
-
|
|
230
|
+
// IA 重做(2026-11-07):未登录点「打开手机端/生成访问码」→ 直接弹登录弹层并说明原因;
|
|
231
|
+
// 这张卡自己的按钮文案也写明「登录并生成访问码」(不再静默失败)。
|
|
232
|
+
assert.ok(textHas(tree, "登录并生成访问码") || textHas(tree, "登录后才能用手机访问"),
|
|
233
|
+
"未登录应给明确的登录引导(不是静默失败,也不是红字报错)");
|
|
231
234
|
assert.ok(!textHas(tree, "尚未登录"), "未登录也不该出现接口报错红字");
|
|
232
235
|
|
|
233
236
|
tree = await plugin.login();
|