@mrrisega/dsh-remote 0.7.0-beta.8 → 0.7.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/dsh-bridge.mjs +17 -17
- package/clients/dsh-remote/dsh-events.mjs +6 -6
- package/clients/dsh-remote/e2ee-client.mjs +7 -7
- package/clients/dsh-remote/e2ee-shim-script.js +9 -9
- package/clients/dsh-remote/mobile-adapter.mjs +294 -43
- package/clients/dsh-remote/test/bridge-gzip.test.mjs +1 -1
- package/clients/dsh-remote/test/device-registration-report.test.mjs +1 -1
- package/clients/dsh-remote/test/dsh-events.test.mjs +10 -10
- package/clients/dsh-remote/test/e2ee-bridge.test.mjs +4 -4
- package/clients/dsh-remote/test/e2ee-client.test.mjs +7 -7
- package/clients/dsh-remote/test/e2ee-shim.test.mjs +1 -1
- package/clients/dsh-remote/test/e2ee-wecrypto-parity.test.mjs +1 -1
- package/clients/dsh-remote/test/harness-cookie-authority.test.mjs +4 -4
- package/clients/dsh-remote/test/machine-fingerprint.test.mjs +2 -2
- package/clients/dsh-remote/test/mobile-adapter-guards.test.mjs +6 -6
- package/clients/dsh-remote/test/mobile-adapter-image.test.mjs +1 -1
- package/clients/dsh-remote/test/mobile-adapter-runtime.test.mjs +5 -5
- package/clients/dsh-remote/test/mobile-adapter-settings.test.mjs +352 -0
- package/clients/dsh-remote/test/mobile-adapter.test.mjs +35 -0
- package/clients/dsh-remote/test/upstream-discovery.test.mjs +1 -1
- package/clients/dsh-remote/test/wechat-channel.test.mjs +5 -5
- package/clients/dsh-remote/test/wechat-runtime.test.mjs +26 -26
- package/clients/dsh-remote/test/wechat-tier-source.test.mjs +2 -2
- package/clients/dsh-remote/wechat-channel.mjs +9 -9
- package/clients/dsh-remote/wechat-runtime.mjs +38 -38
- package/docs/telemetry.md +3 -3
- package/dsh-setup.mjs +27 -27
- package/package.json +3 -3
- package/packages/dsh-remote-web/lib/client.js +56 -56
- package/packages/dsh-remote-web/lib/fix-prompt.js +1 -1
- package/packages/dsh-remote-web/lib/index.js +380 -59
- package/packages/dsh-remote-web/lib/tarball-update.js +1 -1
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +17 -17
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-events.mjs +6 -6
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-client.mjs +7 -7
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim-script.js +9 -9
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +294 -43
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-channel.mjs +9 -9
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-runtime.mjs +38 -38
- package/packages/dsh-remote-web/runtime/clients/dsh-web/native.html +5 -5
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +27 -27
- package/packages/dsh-remote-web/test/account-switch-device.test.mjs +2 -2
- package/packages/dsh-remote-web/test/activation-single-point.test.mjs +1 -1
- package/packages/dsh-remote-web/test/announcements-ui.test.mjs +1 -1
- package/packages/dsh-remote-web/test/connect-loop.test.mjs +1 -1
- package/packages/dsh-remote-web/test/connect-ui.test.mjs +2 -2
- package/packages/dsh-remote-web/test/desktop-compat.test.mjs +4 -4
- package/packages/dsh-remote-web/test/diag-report.test.mjs +1 -1
- package/packages/dsh-remote-web/test/fix-prompt.test.mjs +2 -2
- package/packages/dsh-remote-web/test/harness-restart.test.mjs +1 -1
- package/packages/dsh-remote-web/test/install-profile-targets.test.mjs +3 -3
- package/packages/dsh-remote-web/test/installer-cli.test.mjs +1 -1
- package/packages/dsh-remote-web/test/launchd-domain.test.mjs +2 -2
- package/packages/dsh-remote-web/test/patch-activation.test.mjs +2 -2
- package/packages/dsh-remote-web/test/picker-pin.test.mjs +1 -1
- package/packages/dsh-remote-web/test/plugin-manifest.test.mjs +2 -2
- package/packages/dsh-remote-web/test/quota-absent.test.mjs +4 -4
- package/packages/dsh-remote-web/test/remote-access-ui.test.mjs +2 -2
- package/packages/dsh-remote-web/test/selfheal-restart-guard.test.mjs +420 -0
- package/packages/dsh-remote-web/test/settings-entry-placement.test.mjs +6 -6
- package/packages/dsh-remote-web/test/settings-entry.test.mjs +2 -2
- package/packages/dsh-remote-web/test/settings-open-path.test.mjs +4 -4
- package/packages/dsh-remote-web/test/setup-output.test.mjs +11 -11
- package/packages/dsh-remote-web/test/tarball-update.test.mjs +1 -1
- package/packages/dsh-remote-web/test/telemetry.test.mjs +4 -4
- package/packages/dsh-remote-web/test/uninstall-safety.test.mjs +2 -2
- package/packages/dsh-remote-web/test/watcher-upstream-port.test.mjs +1 -1
- package/packages/dsh-remote-web/test/wechat-bot-ui.test.mjs +7 -7
- package/packages/dsh-remote-web/test/windows-compat.test.mjs +5 -5
- package/scripts/test-net-guard.cjs +1 -1
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ★★ 回归锁:自愈 watcher **不许**把"探测失败"翻译成"每 12 秒杀掉一次隧道"。
|
|
3
|
+
*
|
|
4
|
+
* 现场(线上反馈 `fb_3c4c5a13bc47`,用户 842,Linux fyf-MS-7D42,npx 安装,插件 0.6.18):
|
|
5
|
+
* 用户原话「是给用户列出几个选项选择的那种窗口,总是一会出现、一会儿消失,持续不停。」
|
|
6
|
+
*
|
|
7
|
+
* 只读诊断(`dsh-relay-internal/2026-10-13-反馈fb_3c4c5a13bc47-诊断.md`)给出的证据链:
|
|
8
|
+
* · `audit_log` 里该设备当天 `device.claim` **3567 次**,相邻间隔 p50 **12001ms**、p90 12281ms,
|
|
9
|
+
* 连续 10 小时整点 300 次/小时(300 × 12s = 3600s)。而 `device.claim` 是 **bridge 进程启动**
|
|
10
|
+
* 的必然产物(一个进程只登记一次)→ 3567 次 = 3567 次进程启动;
|
|
11
|
+
* · `relay_sessions` 该设备 236 条 ws 会话里 235 条挤在 2h20m 内,单条时长均值 5.3s、最大 7.4s,
|
|
12
|
+
* 相邻间隔 p50 7.05s(+ 隧道建立 ≈12.3s 一个完整周期);
|
|
13
|
+
* · 每次新 bridge 连接顶掉旧隧道 → 中继 `close(1012 "bridge offline")` 掉该设备**所有**手机镜像
|
|
14
|
+
* WS → 镜像页每 12 秒断线重连并**全量重放**界面(同一份 bytes 反复出现)→ 交互弹窗的临时
|
|
15
|
+
* 界面状态被反复销毁重建 = 用户看到的"一会儿显示、一会儿消失";
|
|
16
|
+
* · 全站性:上百台设备每天 1.1~1.5 万次重登记,多台设备间隔 p50 精确 ≈12.0s
|
|
17
|
+
* (11996/11998/12000/12001/12002ms)—— 只可能来自客户端同一个 12s 常量;
|
|
18
|
+
* · 中继侧对这类 churn 只做了日志抑制,所以**运维侧一直没发现**。
|
|
19
|
+
*
|
|
20
|
+
* 代码落点:`lib/index.js` 的 `scheduleRuntime()`(12s 定时器)→ 就绪判据只有
|
|
21
|
+
* `launchdStatus().running` 与 `manualStatus().watcher.length > 0`(后者靠 `pgrep` 名称匹配,
|
|
22
|
+
* 极易拿到空结果)→ 判据为假就 `startBridge()` → `startBridgeLinux()` → **无条件**
|
|
23
|
+
* `systemctl --user restart dsh-bridge`(restart 会杀掉正在健康运行的 bridge)。
|
|
24
|
+
*
|
|
25
|
+
* 本文件锁死的四件事(对应用户给的四条修复要求):
|
|
26
|
+
* ① **权威判据**:`systemctl --user is-active` 说 active ⇒ 就是"在跑",pgrep 说什么都不算;
|
|
27
|
+
* ② **幂等**:已 active 时自愈**绝不得**调用 `systemctl restart`(假 runner 断言调用次数为 0);
|
|
28
|
+
* ③ **成功即止**:一旦判定就绪,同一进程生命周期内定时器真的停摆(后续 systemctl 调用数冻结);
|
|
29
|
+
* ④ **熔断 + 退避 + 可观测**:最小重启间隔内不重复重启;同一进程内最多 3 次;触顶后停手
|
|
30
|
+
* 并把原因写进**可见**的安装日志(面板「复制诊断信息」与用户主动上报都会带它的尾部)。
|
|
31
|
+
*
|
|
32
|
+
* ⚠️ 测试隔离(与 `linux-bridge.test.mjs` 同一套手法):
|
|
33
|
+
* · 走**真实分支**(删掉 DSH_RELAY_SKIP_SERVICE),所以必须伪造 HOME 与 PATH;
|
|
34
|
+
* · 所有系统命令都是假 PATH 上的假脚本(`systemctl` 只写调用日志 + 读写状态文件),
|
|
35
|
+
* **不会**碰开发者本机上真实的 systemd/launchd;
|
|
36
|
+
* · 预置一份 `.dsh-watcher.pid` 指向**本测试进程**(必然存活)→ `startBridgeDetached`
|
|
37
|
+
* 的"已在跑"分支立刻返回,因此**不会真的派生**任何后台进程。
|
|
38
|
+
*/
|
|
39
|
+
import assert from "node:assert/strict";
|
|
40
|
+
import http from "node:http";
|
|
41
|
+
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from "node:fs";
|
|
42
|
+
import os from "node:os";
|
|
43
|
+
import path from "node:path";
|
|
44
|
+
import test from "node:test";
|
|
45
|
+
|
|
46
|
+
import { apply } from "../lib/index.js";
|
|
47
|
+
import { __scheduleRuntime, __startBridge } from "../lib/index.js";
|
|
48
|
+
import {
|
|
49
|
+
__linuxSystemdUnitState,
|
|
50
|
+
__selfhealRestartGate,
|
|
51
|
+
__noteSelfhealRestart,
|
|
52
|
+
__noteSelfhealBlocked,
|
|
53
|
+
__resetSelfhealGuard,
|
|
54
|
+
__selfhealGuardOf,
|
|
55
|
+
__SELFHEAL_MAX_RESTARTS,
|
|
56
|
+
} from "../lib/index.js";
|
|
57
|
+
|
|
58
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
59
|
+
|
|
60
|
+
/** 本轮上游地址(显式给死,免得依赖 boundWebPort / 端口发现 → 用例结果与开发机状态耦合)。 */
|
|
61
|
+
const UPSTREAM = "http://127.0.0.1:12345";
|
|
62
|
+
/** 模拟"上游地址在抖"时被写回来的旧值。 */
|
|
63
|
+
const UPSTREAM_OLD = "http://127.0.0.1:3080";
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 假 systemctl:**每一次调用都记进日志**(用例据此断言 `restart` 被调用了几次),
|
|
67
|
+
* 状态机放在一个文件里,于是"restart 之后 bridge 真的起来了没有"由用例决定:
|
|
68
|
+
* · DSH_TEST_RESTART_HEALS=1 → restart 把状态置为 active(模拟重启后真的恢复);
|
|
69
|
+
* · =0 → restart 不改变状态(模拟"重启了但起不来")。
|
|
70
|
+
*/
|
|
71
|
+
const FAKE_SYSTEMCTL = `#!/bin/sh
|
|
72
|
+
printf '%s\\n' "$*" >> "$DSH_TEST_SYSTEMCTL_LOG"
|
|
73
|
+
case "$*" in
|
|
74
|
+
*show-environment*) exit 0 ;;
|
|
75
|
+
*is-active*)
|
|
76
|
+
s="$(cat "$DSH_TEST_SYSTEMCTL_STATE" 2>/dev/null)"
|
|
77
|
+
[ -z "$s" ] && s=inactive
|
|
78
|
+
echo "$s"
|
|
79
|
+
[ "$s" = "active" ] && exit 0 || exit 3
|
|
80
|
+
;;
|
|
81
|
+
*daemon-reload*) exit 0 ;;
|
|
82
|
+
*enable*) exit 0 ;;
|
|
83
|
+
*restart*)
|
|
84
|
+
if [ "$DSH_TEST_RESTART_HEALS" = "1" ]; then printf 'active\\n' > "$DSH_TEST_SYSTEMCTL_STATE"; fi
|
|
85
|
+
exit 0
|
|
86
|
+
;;
|
|
87
|
+
esac
|
|
88
|
+
exit 0
|
|
89
|
+
`;
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 隔离环境:假 HOME(systemd unit 落这里)+ 假 systemctl/pgrep/ps + 临时 relayDir。
|
|
93
|
+
* @param {{state?:string, heals?:boolean, minRestartMs?:number, selfhealMs?:number}} opts
|
|
94
|
+
* state = 假 systemd 的初始状态(active/inactive/failed);heals = restart 之后是否变成 active。
|
|
95
|
+
*/
|
|
96
|
+
function makeEnv({ state = "inactive", heals = false, minRestartMs = 60_000, selfhealMs = 25 } = {}) {
|
|
97
|
+
const root = mkdtempSync(path.join(os.tmpdir(), "dsh-selfheal-"));
|
|
98
|
+
const fakeBin = path.join(root, "bin");
|
|
99
|
+
const fakeHome = path.join(root, "home");
|
|
100
|
+
const relayDir = path.join(root, "relay");
|
|
101
|
+
mkdirSync(fakeBin, { recursive: true });
|
|
102
|
+
mkdirSync(fakeHome, { recursive: true });
|
|
103
|
+
mkdirSync(relayDir, { recursive: true });
|
|
104
|
+
|
|
105
|
+
const sysctlLog = path.join(root, "systemctl.log");
|
|
106
|
+
const sysctlState = path.join(root, "systemctl.state");
|
|
107
|
+
writeFileSync(sysctlLog, "");
|
|
108
|
+
writeFileSync(sysctlState, `${state}\n`);
|
|
109
|
+
writeFileSync(path.join(fakeBin, "systemctl"), FAKE_SYSTEMCTL);
|
|
110
|
+
chmodSync(path.join(fakeBin, "systemctl"), 0o755);
|
|
111
|
+
// 假 pgrep:**一个进程都不匹配**(= 本次故障里那个"极易拿到空结果"的探测)。
|
|
112
|
+
// 用例要证明的正是:即使它一无所获,只要 systemd 说 active,就绝不许 restart。
|
|
113
|
+
for (const n of ["pgrep", "ps"]) {
|
|
114
|
+
writeFileSync(path.join(fakeBin, n), "#!/bin/sh\nexit 1\n");
|
|
115
|
+
chmodSync(path.join(fakeBin, n), 0o755);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// 运行环境入口:无相对 import → runtimeReady 判定为就绪(不会去跑 npx 补装)。
|
|
119
|
+
writeFileSync(path.join(relayDir, "dsh-setup.mjs"), "// 测试用运行环境入口(无相对 import)\n");
|
|
120
|
+
// 已登录(local_key 即视为有账号)→ 自愈 watcher 才会走到"服务在不在跑"这一步。
|
|
121
|
+
writeFileSync(path.join(relayDir, ".dsh-config.json"), JSON.stringify({
|
|
122
|
+
api_url: "http://127.0.0.1:1", local_key: "test-local-key", device_id: "dev-test-selfheal",
|
|
123
|
+
}));
|
|
124
|
+
// 上游文件预先写成与 DSH_BRIDGE_UPSTREAM 一致 → publishUpstream() 回报 changed=false
|
|
125
|
+
// (否则"上游刚变过"本身就是一个合法的重启理由,会把用例的意图搅浑)。
|
|
126
|
+
writeFileSync(path.join(relayDir, ".dsh-upstream"), `${UPSTREAM}\n`);
|
|
127
|
+
// ⚠️ 关键隔离:pid 文件指向**本测试进程**(必然存活)→ startBridgeDetached 的
|
|
128
|
+
// "已在跑,别再拉第二个"分支立刻返回 → 用例永远不会真的派生后台 bridge 进程。
|
|
129
|
+
writeFileSync(path.join(relayDir, ".dsh-watcher.pid"), `${process.pid}\n`);
|
|
130
|
+
|
|
131
|
+
const saved = {};
|
|
132
|
+
const keys = ["HOME", "PATH", "DSH_RELAY_PLATFORM", "DSH_RELAY_SKIP_SERVICE", "DSH_REMOTE_TELEMETRY",
|
|
133
|
+
"DSH_RELAY_SELFHEAL_MS", "DSH_RELAY_SELFHEAL_MIN_RESTART_MS", "DSH_RELAY_DEFAULT_API",
|
|
134
|
+
"DSH_BRIDGE_UPSTREAM", "DSH_WEB_URL", "XDG_RUNTIME_DIR", "DSH_BRIDGE_INSTALL_SOURCE",
|
|
135
|
+
"DSH_TEST_SYSTEMCTL_LOG", "DSH_TEST_SYSTEMCTL_STATE", "DSH_TEST_RESTART_HEALS"];
|
|
136
|
+
for (const k of keys) saved[k] = process.env[k];
|
|
137
|
+
|
|
138
|
+
process.env.HOME = fakeHome;
|
|
139
|
+
process.env.PATH = `${fakeBin}${path.delimiter}${saved.PATH || ""}`;
|
|
140
|
+
process.env.DSH_RELAY_PLATFORM = "linux";
|
|
141
|
+
process.env.DSH_REMOTE_TELEMETRY = "0";
|
|
142
|
+
process.env.DSH_RELAY_DEFAULT_API = "http://127.0.0.1:1";
|
|
143
|
+
process.env.DSH_BRIDGE_UPSTREAM = UPSTREAM;
|
|
144
|
+
process.env.XDG_RUNTIME_DIR = path.join(root, "run");
|
|
145
|
+
mkdirSync(process.env.XDG_RUNTIME_DIR, { recursive: true });
|
|
146
|
+
process.env.DSH_RELAY_SELFHEAL_MS = String(selfhealMs);
|
|
147
|
+
process.env.DSH_RELAY_SELFHEAL_MIN_RESTART_MS = String(minRestartMs);
|
|
148
|
+
process.env.DSH_TEST_SYSTEMCTL_LOG = sysctlLog;
|
|
149
|
+
process.env.DSH_TEST_SYSTEMCTL_STATE = sysctlState;
|
|
150
|
+
process.env.DSH_TEST_RESTART_HEALS = heals ? "1" : "0";
|
|
151
|
+
delete process.env.DSH_RELAY_SKIP_SERVICE; // 走真实分支,但系统命令全是假脚本
|
|
152
|
+
delete process.env.DSH_WEB_URL;
|
|
153
|
+
delete process.env.DSH_BRIDGE_INSTALL_SOURCE;
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
root, relayDir, fakeHome, sysctlLog, sysctlState,
|
|
157
|
+
/** systemctl 被调用过的**所有**命令行(一行一次)。 */
|
|
158
|
+
calls: () => readFileSync(sysctlLog, "utf8").split("\n").filter(Boolean),
|
|
159
|
+
/** 断言用:某个子命令被调用了几次。 */
|
|
160
|
+
count: (needle) => readFileSync(sysctlLog, "utf8").split("\n").filter((l) => l.includes(needle)).length,
|
|
161
|
+
installLog: () => {
|
|
162
|
+
const p = path.join(relayDir, ".dsh-setup-install.log");
|
|
163
|
+
return existsSync(p) ? readFileSync(p, "utf8") : "";
|
|
164
|
+
},
|
|
165
|
+
/** 上游文件"抖"回旧值(见护栏用例)。 */
|
|
166
|
+
rewindUpstream: () => writeFileSync(path.join(relayDir, ".dsh-upstream"), `${UPSTREAM_OLD}\n`),
|
|
167
|
+
setState: (s) => writeFileSync(sysctlState, `${s}\n`),
|
|
168
|
+
restore() {
|
|
169
|
+
__resetSelfhealGuard(relayDir);
|
|
170
|
+
for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete process.env[k]; else process.env[k] = v; }
|
|
171
|
+
rmSync(root, { recursive: true, force: true });
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** 起一个只跑插件路由的假宿主(与 `linux-bridge.test.mjs` 同一手法):只为驱动真实的面板端点。 */
|
|
177
|
+
function bootRoutes(relayDir) {
|
|
178
|
+
const routes = new Map();
|
|
179
|
+
const disposers = [];
|
|
180
|
+
apply({
|
|
181
|
+
webServer: { port: 0, register(route) { routes.set(route.path, route.handler); return () => {}; } },
|
|
182
|
+
get() { return null; },
|
|
183
|
+
effect(register) { const d = register(); if (typeof d === "function") disposers.push(d); return d; },
|
|
184
|
+
logger: { info() {}, warn() {} },
|
|
185
|
+
}, { relayDir });
|
|
186
|
+
routes.dispose = () => { for (const d of disposers) { try { d(); } catch { /* ignore */ } } };
|
|
187
|
+
return routes;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async function serveRoutes(routes) {
|
|
191
|
+
const host = http.createServer((req, res) => {
|
|
192
|
+
const handler = routes.get(new URL(req.url, "http://x").pathname);
|
|
193
|
+
(handler || ((_r, rs) => { rs.writeHead(404); rs.end(); }))(req, res);
|
|
194
|
+
});
|
|
195
|
+
await new Promise((resolve) => host.listen(0, "127.0.0.1", resolve));
|
|
196
|
+
return {
|
|
197
|
+
base: `http://127.0.0.1:${host.address().port}`,
|
|
198
|
+
async close() { await new Promise((r) => host.close(r)); },
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ─────────────── ① 权威判据 + ② 幂等:已 active 时自愈绝不得 restart ───────────────
|
|
203
|
+
test("★ ①:systemctl 说 active(而 pgrep 一无所获)→ 自愈 watcher 必须判定「就绪」,restart 调用 0 次", async () => {
|
|
204
|
+
const env = makeEnv({ state: "active" });
|
|
205
|
+
let stop = null;
|
|
206
|
+
try {
|
|
207
|
+
// 前置:权威判据本身要能读出 active(三态里的"明确 active")
|
|
208
|
+
assert.deepEqual(__linuxSystemdUnitState(), { known: true, active: true, raw: "active" });
|
|
209
|
+
|
|
210
|
+
stop = __scheduleRuntime(env.relayDir);
|
|
211
|
+
await sleep(400); // 自愈间隔 25ms → 这段窗口内本会跑十几拍
|
|
212
|
+
|
|
213
|
+
const calls = env.calls();
|
|
214
|
+
assert.equal(env.count("restart"), 0,
|
|
215
|
+
`★ 已 active 就绝不许 restart(那是这台机器上唯一会造成"手机镜像每 12 秒断一次"的动作)。`
|
|
216
|
+
+ `实际调用:\n${calls.join("\n")}`);
|
|
217
|
+
assert.equal(env.count("stop"), 0, "更不许 stop:停掉正在跑的 bridge 会直接断掉用户的远程访问");
|
|
218
|
+
assert.ok(env.calls().some((l) => l.includes("is-active")), "应当用 is-active 做权威判据,而不是猜进程名");
|
|
219
|
+
|
|
220
|
+
// 判定就绪 → 循环停摆:调用数必须冻结(不只是"被限流",是真的不再探了)
|
|
221
|
+
const frozen = env.calls().length;
|
|
222
|
+
await sleep(300);
|
|
223
|
+
assert.equal(env.calls().length, frozen, "★ 判定就绪后定时器必须真的停摆(调用数冻结)");
|
|
224
|
+
|
|
225
|
+
// 可观测:留下"判定就绪、已停止自愈循环"的一行(同一条结论只写一次)
|
|
226
|
+
const log = env.installLog();
|
|
227
|
+
assert.match(log, /自愈判定 bridge 已就绪,已停止自愈循环/, `安装日志必须留下判据与依据,实际:\n${log}`);
|
|
228
|
+
assert.match(log, /systemd --user is-active=active/, "日志要写清依据是 systemd 的权威回答");
|
|
229
|
+
assert.doesNotMatch(log, /自愈重启 bridge/, "既然没重启,就不该有重启记录");
|
|
230
|
+
} finally {
|
|
231
|
+
stop?.();
|
|
232
|
+
env.restore();
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("★ ②:已 active 时反复调用 startBridge(面板重试/自愈轮次)也必须幂等 —— restart 恒为 0 次", () => {
|
|
237
|
+
const env = makeEnv({ state: "active" });
|
|
238
|
+
try {
|
|
239
|
+
for (let i = 0; i < 6; i += 1) {
|
|
240
|
+
const r = __startBridge(env.relayDir);
|
|
241
|
+
assert.equal(r.ok, true, `已 active 时必须如实回报 ok(第 ${i + 1} 次),实际 ${JSON.stringify(r)}`);
|
|
242
|
+
assert.equal(r.reused, true, `已 active 时必须标记 reused(第 ${i + 1} 次)`);
|
|
243
|
+
assert.equal(r.status, "running");
|
|
244
|
+
}
|
|
245
|
+
assert.equal(env.count("restart"), 0,
|
|
246
|
+
`★ 幂等闸门失效:已 active 却调了 restart。实际调用:\n${env.calls().join("\n")}`);
|
|
247
|
+
assert.equal(env.count("daemon-reload"), 0, "既然没必要重启,就不该连 daemon-reload 一起做(避免无谓的 systemd 抖动)");
|
|
248
|
+
assert.equal(env.count("enable"), 0);
|
|
249
|
+
} finally {
|
|
250
|
+
env.restore();
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// ─────────────── ② + ③:确实没在跑才重启,且最小间隔内不重复、成功即止 ───────────────
|
|
255
|
+
|
|
256
|
+
test("★ ②③:真的不 active 时才重启**一次**;成功即止(同一进程生命周期内不再有 restart)", async () => {
|
|
257
|
+
// 模拟"systemd 里它确实没在跑,但 restart 之后真的起来了"
|
|
258
|
+
const env = makeEnv({ state: "inactive", heals: true, minRestartMs: 60_000, selfhealMs: 25 });
|
|
259
|
+
let stop = null;
|
|
260
|
+
try {
|
|
261
|
+
stop = __scheduleRuntime(env.relayDir);
|
|
262
|
+
await sleep(250);
|
|
263
|
+
assert.equal(env.count("restart"), 1,
|
|
264
|
+
`★ 只该重启一次,实际 ${env.count("restart")} 次:\n${env.calls().join("\n")}`);
|
|
265
|
+
|
|
266
|
+
// 成功即止:定时器必须停摆,后续调用数冻结(不是"每 25ms 被限流一次")
|
|
267
|
+
const frozen = env.calls().length;
|
|
268
|
+
await sleep(300);
|
|
269
|
+
assert.equal(env.count("restart"), 1, "★ 判定成功后再也不许有 restart 调用");
|
|
270
|
+
assert.equal(env.calls().length, frozen, "★ 判定成功之后定时器必须停摆(调用数冻结)");
|
|
271
|
+
|
|
272
|
+
const log = env.installLog();
|
|
273
|
+
assert.match(log, /自愈重启 bridge(第 1\/3 次/, `重启必须留可见日志,实际:\n${log}`);
|
|
274
|
+
assert.match(log, /→ running/, "日志要带上重启后的结果");
|
|
275
|
+
assert.match(log, /自愈判定 bridge 已就绪,已停止自愈循环/, "就绪后要写「循环已停」的一行");
|
|
276
|
+
} finally {
|
|
277
|
+
stop?.();
|
|
278
|
+
env.restore();
|
|
279
|
+
}
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("★ ④:最小重启间隔内**不重复**重启,并且写下可见的「已暂缓」日志", () => {
|
|
283
|
+
// 起不来(heals=false)+ 最小间隔 60 秒:真实重启只许发生 1 次,后面的尝试全被拦下。
|
|
284
|
+
const env = makeEnv({ state: "inactive", heals: false, minRestartMs: 60_000 });
|
|
285
|
+
try {
|
|
286
|
+
for (let i = 0; i < 5; i += 1) __startBridge(env.relayDir);
|
|
287
|
+
assert.equal(env.count("restart"), 1,
|
|
288
|
+
`★ 最小间隔(60s)内不得重复 restart,实际 ${env.count("restart")} 次:\n${env.calls().join("\n")}`);
|
|
289
|
+
|
|
290
|
+
const log = env.installLog();
|
|
291
|
+
assert.match(log, /已暂缓自愈重启 bridge:距上次重启不足 60 秒/, `实际:\n${log}`);
|
|
292
|
+
// 去重:同一条结论只说一次(否则每 12 秒一行,用户复制过来的诊断里全是重复行)
|
|
293
|
+
const hits = log.split("\n").filter((l) => l.includes("已暂缓自愈重启 bridge")).length;
|
|
294
|
+
assert.equal(hits, 1, `同一条"已暂缓"结论不得刷屏,实际 ${hits} 行`);
|
|
295
|
+
} finally {
|
|
296
|
+
env.restore();
|
|
297
|
+
}
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
// ─────────────── ④ 熔断触顶:停手 + 明确报错 ───────────────
|
|
301
|
+
|
|
302
|
+
test("★ ④:破坏性重启触顶(本进程最多 3 次)后**停手**,并写下明确的熔断日志", () => {
|
|
303
|
+
// 场景:上游地址在抖(上游文件被另一个组件写回旧值)→ 每轮都会认为"需要重启才能生效"。
|
|
304
|
+
// 这正是护栏存在的意义:哪怕上游真的在抖,也绝不能退化成"每 12 秒杀一次隧道"。
|
|
305
|
+
const env = makeEnv({ state: "active", minRestartMs: 0 });
|
|
306
|
+
try {
|
|
307
|
+
for (let i = 0; i < 6; i += 1) {
|
|
308
|
+
env.rewindUpstream(); // 上游地址又变了 → 又一个"合法的重启理由"
|
|
309
|
+
__startBridge(env.relayDir);
|
|
310
|
+
}
|
|
311
|
+
assert.equal(env.count("restart"), __SELFHEAL_MAX_RESTARTS,
|
|
312
|
+
`★ 同一进程内最多 ${__SELFHEAL_MAX_RESTARTS} 次破坏性重启,实际 ${env.count("restart")} 次:\n${env.calls().join("\n")}`);
|
|
313
|
+
assert.equal(__selfhealGuardOf(env.relayDir).count, __SELFHEAL_MAX_RESTARTS);
|
|
314
|
+
|
|
315
|
+
const log = env.installLog();
|
|
316
|
+
assert.match(log, /已停止自愈重启 bridge:本进程生命周期内已重启 3 次仍未就绪/, `实际:\n${log}`);
|
|
317
|
+
assert.match(log, /已熔断/, "要把「熔断」这个结论明确写给用户/运维");
|
|
318
|
+
assert.match(log, /一键更新/, "要给出下一步动作,而不是只报一个错误");
|
|
319
|
+
const hits = log.split("\n").filter((l) => l.includes("已停止自愈重启 bridge")).length;
|
|
320
|
+
assert.equal(hits, 1, `熔断结论只说一次,实际 ${hits} 行`);
|
|
321
|
+
|
|
322
|
+
// 触顶之后再怎么抖,也不许再动 bridge
|
|
323
|
+
for (let i = 0; i < 3; i += 1) { env.rewindUpstream(); __startBridge(env.relayDir); }
|
|
324
|
+
assert.equal(env.count("restart"), __SELFHEAL_MAX_RESTARTS, "★ 触顶后必须彻底停手");
|
|
325
|
+
} finally {
|
|
326
|
+
env.restore();
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("★ ④ 护栏本身:纯查询语义(不记账)+ 三态边界", () => {
|
|
331
|
+
const env = makeEnv({ state: "inactive", minRestartMs: 1000 });
|
|
332
|
+
try {
|
|
333
|
+
// 纯查询:反复问不许改变状态(否则"只查不做"的路径会凭空吃掉额度)
|
|
334
|
+
for (let i = 0; i < 5; i += 1) assert.equal(__selfhealRestartGate(env.relayDir).allowed, true);
|
|
335
|
+
assert.equal(__selfhealGuardOf(env.relayDir).count, 0, "查询不得记账");
|
|
336
|
+
|
|
337
|
+
__noteSelfhealRestart(env.relayDir, "测试", "running");
|
|
338
|
+
assert.equal(__selfhealGuardOf(env.relayDir).count, 1);
|
|
339
|
+
const tooSoon = __selfhealRestartGate(env.relayDir);
|
|
340
|
+
assert.equal(tooSoon.allowed, false);
|
|
341
|
+
assert.equal(tooSoon.reason, "too-soon");
|
|
342
|
+
assert.ok(tooSoon.waitMs > 0 && tooSoon.waitMs <= 1000, `waitMs 应落在剩余窗口内,实际 ${tooSoon.waitMs}`);
|
|
343
|
+
|
|
344
|
+
__noteSelfhealRestart(env.relayDir, "测试", "running");
|
|
345
|
+
__noteSelfhealRestart(env.relayDir, "测试", "running");
|
|
346
|
+
const exhausted = __selfhealRestartGate(env.relayDir);
|
|
347
|
+
assert.equal(exhausted.allowed, false);
|
|
348
|
+
assert.equal(exhausted.reason, "exhausted");
|
|
349
|
+
// 记一次重启会清掉"被拦下"的去重状态(结论变了要重新说)
|
|
350
|
+
assert.equal(__selfhealGuardOf(env.relayDir).blockedLine, "");
|
|
351
|
+
|
|
352
|
+
__resetSelfhealGuard(env.relayDir);
|
|
353
|
+
assert.equal(__selfhealGuardOf(env.relayDir).count, 0, "桥恢复后要能重新自愈");
|
|
354
|
+
assert.equal(__selfhealRestartGate(env.relayDir).allowed, true);
|
|
355
|
+
} finally {
|
|
356
|
+
env.restore();
|
|
357
|
+
}
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test("★ ④ 护栏文案:被拦下时写进**可见**日志(面板诊断/用户上报会带它)", () => {
|
|
361
|
+
const env = makeEnv({ state: "inactive", minRestartMs: 5000, selfhealMs: 12_000 });
|
|
362
|
+
try {
|
|
363
|
+
assert.equal(__noteSelfhealBlocked(env.relayDir, { reason: "too-soon", count: 1, waitMs: 4000 }), true);
|
|
364
|
+
assert.equal(__noteSelfhealBlocked(env.relayDir, { reason: "too-soon", count: 1, waitMs: 1234 }), false,
|
|
365
|
+
"★ 文案里不得带倒计时秒数,否则每拍文本都不同、去重失效 → 又变成刷屏");
|
|
366
|
+
assert.equal(__noteSelfhealBlocked(env.relayDir, { reason: "exhausted", count: 3, waitMs: 0 }), true);
|
|
367
|
+
const log = env.installLog();
|
|
368
|
+
assert.match(log, /已暂缓自愈重启 bridge/);
|
|
369
|
+
assert.match(log, /已停止自愈重启 bridge/);
|
|
370
|
+
// 文案必须点出"那个 12 秒"本身:它就是这个故障的名字,也是用户能对上号的信息
|
|
371
|
+
assert.match(log, /每 12 秒重启一次/);
|
|
372
|
+
assert.match(log, /每 12 秒杀掉一次正在跑的隧道/);
|
|
373
|
+
} finally {
|
|
374
|
+
env.restore();
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
// ─────────────── 契约锁:护栏只约束"自动",绝不约束"人" ───────────────
|
|
379
|
+
|
|
380
|
+
test("★ 契约:面板「重试」(ensureConnection force=true)必须无视已熔断的护栏,仍然把 bridge 拉起来", async () => {
|
|
381
|
+
// 自愈间隔设成 10 分钟 → 定时器在本用例里绝不会自己跑,行为只来自面板这一次显式请求。
|
|
382
|
+
const env = makeEnv({ state: "inactive", heals: true, minRestartMs: 60_000, selfhealMs: 600_000 });
|
|
383
|
+
const routes = bootRoutes(env.relayDir);
|
|
384
|
+
const srv = await serveRoutes(routes);
|
|
385
|
+
try {
|
|
386
|
+
// 前置:模拟"自动自愈已经把 3 次额度用完并熔断"
|
|
387
|
+
for (let i = 0; i < __SELFHEAL_MAX_RESTARTS; i += 1) __noteSelfhealRestart(env.relayDir, "前置", "running");
|
|
388
|
+
assert.equal(__selfhealRestartGate(env.relayDir).reason, "exhausted", "前置条件:护栏必须已熔断");
|
|
389
|
+
assert.equal(__startBridge(env.relayDir).status, "selfheal-throttled", "前置条件:自动路径此刻确实被拦下");
|
|
390
|
+
|
|
391
|
+
// 用户点面板「重试」→ POST /dsh-remote/connect/retry(内部 force=true)
|
|
392
|
+
const res = await fetch(`${srv.base}/dsh-remote/connect/retry`, { method: "POST" });
|
|
393
|
+
assert.equal(res.status, 200);
|
|
394
|
+
assert.equal(env.count("restart"), 1,
|
|
395
|
+
`★ 人工操作必须永远立即放行(与 resetProvisionRetry 同一原则)——`
|
|
396
|
+
+ `否则熔断会变成"用户被自己的插件锁在门外"。实际调用:\n${env.calls().join("\n")}`);
|
|
397
|
+
// 账号切换同理(resetBridgeForAccountChange 里也清了额度),这里只锁住"重试"这一条即可
|
|
398
|
+
assert.equal(__linuxSystemdUnitState().active, true, "重试之后 bridge 应当真的起来了");
|
|
399
|
+
} finally {
|
|
400
|
+
await srv.close();
|
|
401
|
+
routes.dispose();
|
|
402
|
+
env.restore();
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
// ─────────────── 隔离自检:本文件不得碰真实系统 ───────────────
|
|
407
|
+
|
|
408
|
+
test("★ 隔离自检:本文件走的都是假 systemctl(真实系统命令一个都不碰)", () => {
|
|
409
|
+
const env = makeEnv({ state: "inactive" });
|
|
410
|
+
try {
|
|
411
|
+
__startBridge(env.relayDir);
|
|
412
|
+
const calls = env.calls();
|
|
413
|
+
assert.ok(calls.length > 0, "前置:假 systemctl 必须真的被调用过(否则下面的断言恒真)");
|
|
414
|
+
assert.ok(calls.every((l) => l.startsWith("--user ")), `假 runner 收到的是 systemctl --user 系列命令:${calls.join(" | ")}`);
|
|
415
|
+
// process.execPath 是本机 node,但 pid 文件指向本测试进程 → 绝不会派生真的 watcher
|
|
416
|
+
assert.equal(env.installLog().includes("脱离进程启动 bridge 失败"), false);
|
|
417
|
+
} finally {
|
|
418
|
+
env.restore();
|
|
419
|
+
}
|
|
420
|
+
});
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
// dsh-remote-web 侧栏快捷入口「落点」回归(2026-10-09
|
|
1
|
+
// dsh-remote-web 侧栏快捷入口「落点」回归(2026-10-09 桌面端故障)。
|
|
2
2
|
//
|
|
3
|
-
//
|
|
3
|
+
// 【故障】桌面端(DSH 0.2)设置面板本身就是 `role="dialog" aria-modal="true"` 的弹层,
|
|
4
4
|
// 它左侧栏目列表的第一项叫 **「通用设置」**。旧代码用「文本包含『设置』」(`indexOf !== -1`)
|
|
5
5
|
// 去找侧栏那颗齿轮按钮 → **命中的是弹层里的「通用设置」**,于是我们的「📱 远程访问」
|
|
6
|
-
//
|
|
6
|
+
// 快捷按钮被插进了设置面板栏目列表的最上面。用户截图反馈:「植入错了地方」。
|
|
7
7
|
//
|
|
8
8
|
// 本用例把 client.js 里的导航注入辅助函数**原样切出来**,喂一个最小 DOM,逐条锁住:
|
|
9
9
|
// ① 官方「设置」触发器只按**语义锚点**(aria-haspopup="dialog")识别,且必须在弹层之外;
|
|
@@ -129,13 +129,13 @@ function desktopFixture() {
|
|
|
129
129
|
|
|
130
130
|
// ───────────────────────── 用例 ─────────────────────────
|
|
131
131
|
|
|
132
|
-
test("★
|
|
132
|
+
test("★ 回归锁:官方「设置」触发器只认语义锚点,且必须落在**设置弹层之外**", () => {
|
|
133
133
|
const { doc, trigger } = desktopFixture();
|
|
134
134
|
const { pickSettingsTrigger } = loadNavHelpers(doc);
|
|
135
135
|
assert.equal(pickSettingsTrigger(), trigger, "必须命中侧栏那颗 aria-haspopup=dialog 的齿轮按钮");
|
|
136
136
|
});
|
|
137
137
|
|
|
138
|
-
test("★ 2026-10-11
|
|
138
|
+
test("★ 2026-10-11 用户实测:点入口弹出的是 token 用量弹窗 —— 官方设置槽位必须优先于底部槽邻居", () => {
|
|
139
139
|
// 现场:入口挂在 sidebar.footer.action,而同一底部区域的「token 本轮用量」按钮
|
|
140
140
|
// 也带 aria-haspopup="dialog"(甚至带快捷键)→ 旧实现"谁先扫到谁"就点中了它。
|
|
141
141
|
// 现在:① 官方设置槽位里的按钮排第一;② 底部槽(我们所在那块)里的候选直接排除。
|
|
@@ -159,7 +159,7 @@ test("★ 2026-10-11 业主实测:点入口弹出的是 token 用量弹窗 —
|
|
|
159
159
|
assert.equal(pickSettingsTrigger(), trigger, "★ 必须选中官方设置槽位里的齿轮,而不是 token 用量按钮");
|
|
160
160
|
});
|
|
161
161
|
|
|
162
|
-
test("★
|
|
162
|
+
test("★ 回归锁:弹层里的「通用设置」永远不能成为落点", () => {
|
|
163
163
|
const { doc, general } = desktopFixture();
|
|
164
164
|
const { pickSettingsTrigger, pickNavByToken } = loadNavHelpers(doc);
|
|
165
165
|
assert.notEqual(pickSettingsTrigger(), general, "「通用设置」在弹层内,绝不能被当成侧栏设置按钮");
|
|
@@ -165,7 +165,7 @@ function loadPlugin(opts = {}) {
|
|
|
165
165
|
test("入口:settings.section 栏目(id/order/label)+ 侧栏底部官方槽 sidebar.footer.action", () => {
|
|
166
166
|
const plugin = loadPlugin();
|
|
167
167
|
|
|
168
|
-
// 2026-10-10
|
|
168
|
+
// 2026-10-10 三修(用户约定「就要放侧栏底部那个位置」):入口改注册进**官方列表槽**
|
|
169
169
|
// `sidebar.footer.action`(宿主为侧栏底部区域提供的扩展点;0.2 实测该槽当前为空)。
|
|
170
170
|
// 不再自己注入 DOM —— 前两次都是"自己找落点"找错的。
|
|
171
171
|
assert.ok(plugin.injects.has("sidebar.footer.action"), "应注入官方侧栏底部槽");
|
|
@@ -359,7 +359,7 @@ test("源码约束:侧栏注入「远程访问」按钮与官方「设置」
|
|
|
359
359
|
});
|
|
360
360
|
|
|
361
361
|
test("源码约束:侧栏入口走官方槽位/无浮动面板/无切换账号;命名统一为「远程访问」", () => {
|
|
362
|
-
// 2026-10-10
|
|
362
|
+
// 2026-10-10 三修:侧栏入口**改回**官方槽位(用户约定「就要放侧栏底部那个位置」),
|
|
363
363
|
// 所以这条从"不得出现 footer.action"翻转为"必须走官方槽位、不得自己注入 DOM"。
|
|
364
364
|
assert.match(SOURCE, /slots\.inject\("sidebar\.footer\.action"/);
|
|
365
365
|
assert.doesNotMatch(SOURCE, /insertBefore\(btn, host\)/, "不得再自己插 DOM 找落点");
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 「点入口怎么打开设置」契约测试(2026-10-11
|
|
2
|
+
* 「点入口怎么打开设置」契约测试(2026-10-11 用户反馈后的最终形态)。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* 背景(用户原话):
|
|
5
5
|
* ① 「账号菜单里面的那个删掉。只保留官方槽位的那个。」
|
|
6
6
|
* ② 「设置界面出现了两个远程访问菜单。」—— 因为账号菜单注入的判据里加了"以设置结尾",
|
|
7
7
|
* 而「通用设置」正好以"设置"结尾 → 注入行被插进了**设置面板**里,多出一个"远程访问"。
|
|
@@ -63,9 +63,9 @@ test("★ 设置面板打开后自动点中「远程访问」栏目", () => {
|
|
|
63
63
|
assert.deepEqual(clicked, ["远程访问"], "面板开了要顺手切到我们的栏目");
|
|
64
64
|
});
|
|
65
65
|
|
|
66
|
-
test("★
|
|
66
|
+
test("★ 不再往账号菜单里插任何东西(用户明确要求删掉)", () => {
|
|
67
67
|
for (const banned of ["dru-menu-row", "accountMenuInjectTick", "MENU_ENTRY_SIG"]) {
|
|
68
|
-
assert.ok(!SOURCE.includes(banned), `不得再往账号菜单里插条目:${banned}
|
|
68
|
+
assert.ok(!SOURCE.includes(banned), `不得再往账号菜单里插条目:${banned}(已按用户要求删除)`);
|
|
69
69
|
}
|
|
70
70
|
});
|
|
71
71
|
|
|
@@ -26,7 +26,7 @@ const SETUP = join(HERE, "..", "..", "..", "dsh-setup.mjs");
|
|
|
26
26
|
const src = readFileSync(SETUP, "utf8");
|
|
27
27
|
|
|
28
28
|
test("测试隔离:unset 系统操作开关的用例必须同时伪造 HOME(否则污染真实自启动)", () => {
|
|
29
|
-
//
|
|
29
|
+
// 故障回顾:harness-restart 用例 unset DSH_RELAY_SKIP_SERVICE 去走真实分支,
|
|
30
30
|
// 但没伪造 HOME → 插件写 plist 的路径是 join(homedir(), "Library/LaunchAgents/…"),
|
|
31
31
|
// 于是把**指向测试临时目录**的 plist 覆盖到开发者真实的 ~/Library/LaunchAgents,
|
|
32
32
|
// 临时目录随即被删 → 真实 bridge 自启动彻底失效(本机实测踩到,macOS 26 用户反馈排查时发现)。
|
|
@@ -105,13 +105,13 @@ test("安装输出:跳过/不支持自启动时不得自相矛盾地显示「运
|
|
|
105
105
|
});
|
|
106
106
|
|
|
107
107
|
test("安装输出:服务状态字符串仍是插件面板可解析的稳定契约", () => {
|
|
108
|
-
// 插件半按 running / 未运行
|
|
109
|
-
assert.ok(/\? `✅ 运行中/.test(src), "running
|
|
110
|
-
assert.ok(/`未运行 \(\$\{st\.detail \|\| st\.status\}\)`/.test(src), "
|
|
108
|
+
// 插件半按 running / 未运行 解析,不能因为排版调整改约定
|
|
109
|
+
assert.ok(/\? `✅ 运行中/.test(src), "running 约定保留(带 pid)");
|
|
110
|
+
assert.ok(/`未运行 \(\$\{st\.detail \|\| st\.status\}\)`/.test(src), "未运行约定保留(带原因)");
|
|
111
111
|
});
|
|
112
112
|
|
|
113
113
|
test("版本比较:预设版 < 同号正式版;beta.10 < beta.11(更新提示不能误判)", () => {
|
|
114
|
-
//
|
|
114
|
+
// 故障背景:面板原本用 `latest !== current` 判断"有没有新版本",于是
|
|
115
115
|
// ① 0.6.4-beta.11 与 0.6.4-beta.10 无法比大小;② 正式版 0.6.4 发布后,
|
|
116
116
|
// 装了 0.6.4-beta.11 的人反而被当成"落后"(其实预设版 semver 低于正式版)。
|
|
117
117
|
const src = readFileSync(join(HERE, "..", "lib", "index.js"), "utf8");
|
|
@@ -135,8 +135,8 @@ test("版本比较:预设版 < 同号正式版;beta.10 < beta.11(更新提
|
|
|
135
135
|
assert.match(src, /cmp === null \? Boolean\(latest && latest !== current\) : cmp > 0/);
|
|
136
136
|
});
|
|
137
137
|
|
|
138
|
-
test("★ 插件覆盖必须原子:staging → 必需文件自检 → rename 切换(2026-09-26「dsh
|
|
139
|
-
//
|
|
138
|
+
test("★ 插件覆盖必须原子:staging → 必需文件自检 → rename 切换(2026-09-26「dsh 起不来」故障)", () => {
|
|
139
|
+
// 故障:旧实现 `rmSync(dest)` → `cpSync(pluginDir, dest)`,于是拷贝期间 dest 是"半个插件":
|
|
140
140
|
// dsh 恰在此刻启动 → package.json 已声明 bundle,但 cordis.patch.yml 还没拷到 →
|
|
141
141
|
// `failed to read overlay … cordis.patch.yml: ENOENT` → **整个 GUI 起不来**。
|
|
142
142
|
// 时间证据:dsh 报错 23:46:03,cordis.patch.yml 写完 23:46:05(差 2 秒)。
|
|
@@ -153,12 +153,12 @@ test("★ 插件覆盖必须原子:staging → 必需文件自检 → rename
|
|
|
153
153
|
assert.match(fn, /renameSync\(backup, dest\)/, "切换失败必须把 backup 改回来(旧版仍可用)");
|
|
154
154
|
// 反向锁:绝不允许回到"先 rm dest、再 cpSync 逐文件"的旧写法
|
|
155
155
|
assert.ok(!/fs\.rmSync\(dest,\s*\{\s*recursive:\s*true,\s*force:\s*true\s*\}\);\s*\n\s*fs\.cpSync\(/.test(fn),
|
|
156
|
-
"🔴 不得再出现「先删 dest
|
|
156
|
+
"🔴 不得再出现「先删 dest 再原地逐文件拷贝」——那正是这次故障的成因");
|
|
157
157
|
assert.match(fn, /\.staging-\$\{process\.pid\}|\.staging-/, "被强杀留下的 staging 要能清理");
|
|
158
158
|
});
|
|
159
159
|
|
|
160
160
|
test("★ 插件拷贝必须保留**自带的 runtime/node_modules**(ws 是桥唯一的裸依赖)", () => {
|
|
161
|
-
// 2026-10-10
|
|
161
|
+
// 2026-10-10 故障形态:过滤规则写的是"任何一段叫 node_modules 就剔除",
|
|
162
162
|
// 本意只想去掉插件自己的依赖目录(那些由 profile 的包管理器管),
|
|
163
163
|
// 却把 `runtime/node_modules/ws` 一起剔了 —— 而这份运行时正是"只有插件半"的机器
|
|
164
164
|
// **自愈补装时的唯一来源**。剔掉它 → 自愈出来的运行环境缺 ws
|
|
@@ -228,8 +228,8 @@ test("★ 自启动配置里可以钉住上游(多 profile 时代,各宿主
|
|
|
228
228
|
});
|
|
229
229
|
|
|
230
230
|
test("★ 桌面应用在运行时,绝不改写它的 profile(否则应用会自己退出/重启)", () => {
|
|
231
|
-
// 2026-10-11
|
|
232
|
-
//
|
|
231
|
+
// 2026-10-11 故障:在应用运行中改写 ~/.dsh/profiles/desktop/ 的插件文件 →
|
|
232
|
+
// 应用随即自己退出/重启(用户:"怎么又触发了 dsh 的关闭")。安装器文案早就写了"先完全退出应用",
|
|
233
233
|
// 但必须由程序强制,不能靠人记得。
|
|
234
234
|
const src = readFileSync(SETUP, "utf8");
|
|
235
235
|
assert.match(src, /function dshDesktopAppRunning\(/, "要有「桌面应用是否在运行」的探测");
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 零依赖 tarball 更新通道的回归测试(node:test,零框架)。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* 背景(真实故障,见 lib/tarball-update.js 顶部注释):
|
|
5
5
|
* Windows 上「一键更新」走的 `npx` 在 Node ≥20.12 起同步 EINVAL,0.6.6 及更早的升级通道
|
|
6
6
|
* 本身是坏的 —— 79 台点过一键更新的 Windows 机器全部 update_failed,且产品内无出路。
|
|
7
7
|
* 本文件就是给替代通道(HTTPS 取 tarball → 内置 zlib + 自解析 tar 落 staging)上锁:
|
|
@@ -268,7 +268,7 @@ test("触发点:plugin_loaded → install_started → runtime_ready → bridge
|
|
|
268
268
|
|
|
269
269
|
const { host, base } = await serve(routes);
|
|
270
270
|
try {
|
|
271
|
-
// ③ 运行环境补齐(dsh-setup.mjs 落地)→
|
|
271
|
+
// ③ 运行环境补齐(dsh-setup.mjs 落地)→ 面板轮询触达探测 → runtime_ready
|
|
272
272
|
await writeFile(path.join(env.relayDir, "dsh-setup.mjs"), "// runtime stub");
|
|
273
273
|
let r = await bridgeStatus(base);
|
|
274
274
|
assert.equal(r.ok, true);
|
|
@@ -351,7 +351,7 @@ test("隐私审计:对抗性 extra 字段(手机号/hostname/machine_fp/路
|
|
|
351
351
|
// 直接往唯一的构造点灌脏数据:字段白名单 + 事件名校验必须把它们全部丢掉
|
|
352
352
|
TELEMETRY.record(env.relayDir, "install_started", { device_id: "dev-telemetry", machine_fp: "fp-abc" });
|
|
353
353
|
TELEMETRY.record(env.relayDir, "plugin_loaded", { hostname: "mac.local", username: "mac" });
|
|
354
|
-
TELEMETRY.record(env.relayDir, "panel_opened", { path: "/
|
|
354
|
+
TELEMETRY.record(env.relayDir, "panel_opened", { path: "/home/user/relay", phone: "13800000000" });
|
|
355
355
|
TELEMETRY.record(env.relayDir, "update_started", { email: "a@b.c", token: "jwt", password: "pw" });
|
|
356
356
|
TELEMETRY.record(env.relayDir, "harness_restart", { ip: "1.2.3.4" });
|
|
357
357
|
// 白名单外的事件名:一条都不入队
|
|
@@ -554,7 +554,7 @@ test("冻结契约:既有事件名与既有字段一字不改(本次只新
|
|
|
554
554
|
assert.match(String(ev.node), /^\d+$/);
|
|
555
555
|
assert.ok(!("fail_code" in ev), "fail_code 仍只出现在 *_failed 事件上");
|
|
556
556
|
assert.equal(TELEMETRY.eventOf("user_login"), null, "白名单外事件名照旧不发");
|
|
557
|
-
// 通道与"一键更新"同源:面板看到的通道 =
|
|
557
|
+
// 通道与"一键更新"同源:面板看到的通道 = 上报的通道(两处约定必须是一处)
|
|
558
558
|
assert.equal(ev.channel, TELEMETRY.updateTag);
|
|
559
559
|
});
|
|
560
560
|
|
|
@@ -845,7 +845,7 @@ test("面板可见性:关于卡片写明「匿名统计 + 关闭方式」,
|
|
|
845
845
|
"客户端上报的事件,不是服务端的实时存量",
|
|
846
846
|
"bot_id",
|
|
847
847
|
]) {
|
|
848
|
-
assert.ok(doc.includes(line), "docs/telemetry.md
|
|
848
|
+
assert.ok(doc.includes(line), "docs/telemetry.md 必须写明微信通道事件的约定边界:" + line);
|
|
849
849
|
}
|
|
850
850
|
for (const code of TELEMETRY.failCodes) assert.ok(doc.includes(code), "docs/telemetry.md 必须列出失败码:" + code);
|
|
851
851
|
});
|
|
@@ -89,7 +89,7 @@ async function plantProfile(home, { extraPatch = "", unmarked = false } = {}) {
|
|
|
89
89
|
fs.mkdirSync(path.join(home, "relay"), { recursive: true });
|
|
90
90
|
// ⚠️ api_url 必须指向本地死端口:这些是**假账号**,一旦按默认地址请求生产,
|
|
91
91
|
// /api/device-login 会带着假密码失败,而服务端登录限流是按出口 IP 计的(5 次/15 分钟)——
|
|
92
|
-
// 实测结果是把**本机真实的 bridge** 连坐进限流(2026-09-19
|
|
92
|
+
// 实测结果是把**本机真实的 bridge** 连坐进限流(2026-09-19 故障)。
|
|
93
93
|
fs.writeFileSync(path.join(home, "relay", ".dsh-config.json"),
|
|
94
94
|
JSON.stringify({ phone: "13800000000", password: "pw", api_url: "http://127.0.0.1:1" }));
|
|
95
95
|
return { profile, patchFile: path.join(profile, "cordis.patch.yml") };
|
|
@@ -418,7 +418,7 @@ test("安全:三处机密写入都要显式收紧 ACL(Windows 上 mode 0o600
|
|
|
418
418
|
});
|
|
419
419
|
|
|
420
420
|
test("安全:Cookie 的每个写入点都必须紧跟着 hardenFile(按 authority 分文件后不得漏收)", () => {
|
|
421
|
-
// 0.7.0-beta.2 把 Cookie 从「一份共享文件」改成「本宿主专属 + 旧版镜像」两份(见 P0-2
|
|
421
|
+
// 0.7.0-beta.2 把 Cookie 从「一份共享文件」改成「本宿主专属 + 旧版镜像」两份(见 P0-2 故障根治)。
|
|
422
422
|
// 写点从 1 个变 2 个 —— 逐个按变量名断言,漏收的那一份在 Windows 上就是 ACL 敞开状态。
|
|
423
423
|
const mint = INDEX_SRC.slice(INDEX_SRC.indexOf("async function mintHarnessCookie"), INDEX_SRC.indexOf("function readHarnessCookie"));
|
|
424
424
|
assert.ok(mint.length > 0, "应能定位到 mintHarnessCookie(找不到说明函数被改名,请同步本用例)");
|