@mrrisega/dsh-remote 0.6.17 → 0.6.18-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/e2ee-shim-script.js +203 -11
- package/clients/dsh-remote/mobile-adapter.mjs +404 -29
- package/clients/dsh-remote/test/e2ee-shim.test.mjs +43 -1
- package/clients/dsh-remote/test/mobile-adapter-guards.test.mjs +145 -4
- package/clients/dsh-remote/test/mobile-adapter-runtime.test.mjs +95 -6
- package/clients/dsh-remote/wechat-runtime.mjs +25 -2
- package/docs/telemetry.md +7 -1
- package/dsh-setup.mjs +152 -3
- package/package.json +1 -1
- package/packages/dsh-remote-web/lib/client.js +754 -39
- package/packages/dsh-remote-web/lib/index.js +279 -6
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim-script.js +203 -11
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +404 -29
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-runtime.mjs +25 -2
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +152 -3
- package/packages/dsh-remote-web/test/account-switch-device.test.mjs +20 -2
- package/packages/dsh-remote-web/test/announcements-proxy.test.mjs +310 -0
- package/packages/dsh-remote-web/test/announcements-ui.test.mjs +524 -0
- package/packages/dsh-remote-web/test/connect-stuck-visibility.test.mjs +4 -1
- package/packages/dsh-remote-web/test/diag-report.test.mjs +4 -1
- package/packages/dsh-remote-web/test/harness-restart.test.mjs +5 -1
- package/packages/dsh-remote-web/test/installer-cli.test.mjs +1 -1
- package/packages/dsh-remote-web/test/launchd-domain.test.mjs +26 -0
- package/packages/dsh-remote-web/test/picker-pin.test.mjs +32 -2
- package/packages/dsh-remote-web/test/plugin-link.test.mjs +3 -1
- package/packages/dsh-remote-web/test/plugin-manifest.test.mjs +2 -2
- package/packages/dsh-remote-web/test/runtime-bundle.test.mjs +4 -1
- package/packages/dsh-remote-web/test/setup-output.test.mjs +22 -0
- package/packages/dsh-remote-web/test/telemetry.test.mjs +29 -10
- package/packages/dsh-remote-web/test/uninstall-safety.test.mjs +3 -3
- package/packages/dsh-remote-web/test/wechat-bot-ui.test.mjs +8 -3
- package/packages/dsh-remote-web/test/windows-compat.test.mjs +7 -4
|
@@ -1173,12 +1173,35 @@ export class WeChatRuntime {
|
|
|
1173
1173
|
} catch { return false; }
|
|
1174
1174
|
}
|
|
1175
1175
|
|
|
1176
|
-
/**
|
|
1176
|
+
/**
|
|
1177
|
+
* 可切换会话列表(`/ls` 与 `/use` **共用同一份**)—— 过滤必须收口在这里。
|
|
1178
|
+
*
|
|
1179
|
+
* 为什么(业主反馈「/ls 列出一堆(未命名)会话」):`listSessions` 返回的会话里有大量**空白会话**
|
|
1180
|
+
* (新建后一句话没说的坑),官方已经在数据里标了 `blank`(`dsh-events.mjs:1654` 解析出来却
|
|
1181
|
+
* 一直没人用)。规则:只留 **有名字 / 正在跑 / 当前选中** 的会话;`blank === true` 一律剔除。
|
|
1182
|
+
*
|
|
1183
|
+
* ⚠️ 过滤**只能**放在这里(或同步过滤 sessionIndex):`/ls` 渲染 1..N 编号、`/use <n>` 按下标取
|
|
1184
|
+
* 会话、而 `sessionIndex`(/use 的列表来源)就是本函数的返回值 —— 任何一处单独过滤都会让
|
|
1185
|
+
* 两边编号分叉(用户 `/ls` 看到第 2 条是 A,`/use 2` 却切到 B)。
|
|
1186
|
+
* ⚠️ 老版本 DSH 不带 `blank` 字段:`undefined === true` 为假 → 自动退化成"有名字/在跑/当前",
|
|
1187
|
+
* 不会因为升级或降级把整张列表清空。
|
|
1188
|
+
*/
|
|
1189
|
+
#usableSessions(list) {
|
|
1190
|
+
return (Array.isArray(list) ? list : []).filter((s) => {
|
|
1191
|
+
if (!s || typeof s !== "object") return false;
|
|
1192
|
+
if (s.blank === true) return false; // 官方明确标记的空白会话
|
|
1193
|
+
if (s.sessionId === this.currentSessionId) return true; // 当前选中:永远留在列表里
|
|
1194
|
+
if (s.running === true) return true; // 正在跑
|
|
1195
|
+
return Boolean(String(s.title || "").trim()); // 有名字
|
|
1196
|
+
});
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
/** 拉一次会话列表(带标题),并记住 1-based 序号供 /use 使用(列表已按 #usableSessions 过滤)。 */
|
|
1177
1200
|
async #sessions() {
|
|
1178
1201
|
if (!this.subscriber || typeof this.subscriber.listSessions !== "function") return null;
|
|
1179
1202
|
const r = await this.subscriber.listSessions();
|
|
1180
1203
|
if (!r || !r.ok) return null;
|
|
1181
|
-
this.sessionIndex = r.sessions || [];
|
|
1204
|
+
this.sessionIndex = this.#usableSessions(r.sessions || []);
|
|
1182
1205
|
return this.sessionIndex;
|
|
1183
1206
|
}
|
|
1184
1207
|
|
|
@@ -1125,6 +1125,61 @@ function loginRateLimitUntil() {
|
|
|
1125
1125
|
return 0;
|
|
1126
1126
|
}
|
|
1127
1127
|
|
|
1128
|
+
/* ── 守护启动锁:把"一堆守护同时被拉起"的惊群收敛成串行 ─────────────────────────────
|
|
1129
|
+
*
|
|
1130
|
+
* 🔴 2026-09-26 真机事故(dsh web 启动故障的连带伤害):一次重启窗口里同时出现了
|
|
1131
|
+
* **6 个 `dsh-setup.mjs run` 守护 + 6 个 bridge**,全部用同一个 deviceId 去 router 注册,
|
|
1132
|
+
* 互相把对方顶掉 —— bridge 日志刷满
|
|
1133
|
+
* `隧道断开(code=4000, replaced by new connection)` → `2s 后重连` → 再被顶掉,
|
|
1134
|
+
* 手机侧表现为隧道无限抖动、随机不可用。
|
|
1135
|
+
* 成因是 `runBridge()` 的去重是**先读 pid 文件、后写 pid 文件**:N 个实例在同一瞬间启动时,
|
|
1136
|
+
* 每个都读到"还没有别人" → 一起活下来。launchd 的 `com.dshremote.bridge`(KeepAlive) 与
|
|
1137
|
+
* 插件自愈拉起的脱离进程又都会各自拉一个,正好构成惊群。
|
|
1138
|
+
*
|
|
1139
|
+
* 修法:进入去重逻辑**之前**先抢一把原子锁(`wx` = 排他创建)。抢到的人才继续走原来的
|
|
1140
|
+
* supervisor 判定;抢不到的人只在"被监管者"时才稍等(它必须能接管),否则直接让位。
|
|
1141
|
+
* 锁文件带 `pid:时间戳`,超过 STALE 就视为残留(进程被强杀时留下的),可安全抢占 ——
|
|
1142
|
+
* **绝不允许一把陈旧的锁把 bridge 永久挡在门外**。
|
|
1143
|
+
*/
|
|
1144
|
+
const WATCHER_SPAWN_LOCK = ".dsh-watcher-spawn.lock";
|
|
1145
|
+
const SPAWN_LOCK_STALE_MS = 30_000;
|
|
1146
|
+
const SPAWN_LOCK_WAIT_MS = 15_000;
|
|
1147
|
+
|
|
1148
|
+
function tryAcquireSpawnLock() {
|
|
1149
|
+
const f = path.join(CONFIG_DIR, WATCHER_SPAWN_LOCK);
|
|
1150
|
+
try {
|
|
1151
|
+
fs.mkdirSync(CONFIG_DIR, { recursive: true });
|
|
1152
|
+
const fd = fs.openSync(f, "wx"); // 排他创建:要么拿到,要么 EEXIST
|
|
1153
|
+
fs.writeSync(fd, `${process.pid}:${Date.now()}`);
|
|
1154
|
+
fs.closeSync(fd);
|
|
1155
|
+
return true;
|
|
1156
|
+
} catch {
|
|
1157
|
+
// 已存在:可能是残留(强杀留下的),也可能是真有人在启动
|
|
1158
|
+
try {
|
|
1159
|
+
const [pidStr, tsStr] = String(fs.readFileSync(f, "utf8")).split(":");
|
|
1160
|
+
const pid = Number(pidStr);
|
|
1161
|
+
const ts = Number(tsStr);
|
|
1162
|
+
const stale = !Number.isFinite(ts) || Date.now() - ts > SPAWN_LOCK_STALE_MS || !pidAlive(pid);
|
|
1163
|
+
if (stale) {
|
|
1164
|
+
fs.rmSync(f, { force: true });
|
|
1165
|
+
const fd = fs.openSync(f, "wx");
|
|
1166
|
+
fs.writeSync(fd, `${process.pid}:${Date.now()}`);
|
|
1167
|
+
fs.closeSync(fd);
|
|
1168
|
+
return true;
|
|
1169
|
+
}
|
|
1170
|
+
} catch { /* 读不到/抢不到 → 当作没拿到 */ }
|
|
1171
|
+
return false;
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
function releaseSpawnLock() {
|
|
1176
|
+
const f = path.join(CONFIG_DIR, WATCHER_SPAWN_LOCK);
|
|
1177
|
+
try {
|
|
1178
|
+
const owner = Number(String(fs.readFileSync(f, "utf8")).split(":")[0]);
|
|
1179
|
+
if (owner === process.pid) fs.rmSync(f, { force: true }); // 只删自己那把
|
|
1180
|
+
} catch { /* 没锁/已被抢 → 忽略 */ }
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1128
1183
|
// ---------- run:前台跑 bridge(带配置 + 自启动 watcher) ----------
|
|
1129
1184
|
async function runBridge() {
|
|
1130
1185
|
// 去重(所有平台):自启动服务、登录任务、插件半的脱离进程兜底都可能拉起 watcher,
|
|
@@ -1138,6 +1193,18 @@ async function runBridge() {
|
|
|
1138
1193
|
// 只剩插件面板的「启动」能救回来。
|
|
1139
1194
|
// 被监管者拉起 = 我们才是那个该活下来的实例 → 请游离实例让位,然后接管。
|
|
1140
1195
|
{
|
|
1196
|
+
// 抢启动锁(见上方长注释):把同时被拉起的多个实例串行化,杜绝"大家都读到没有别人"。
|
|
1197
|
+
const supervised = String(process.env.XPC_SERVICE_NAME || "");
|
|
1198
|
+
let locked = tryAcquireSpawnLock();
|
|
1199
|
+
if (!locked && supervised) {
|
|
1200
|
+
// 被监管者必须能接管:等一会儿再抢(游离实例写完 pid 文件就会释放)
|
|
1201
|
+
const deadline = Date.now() + SPAWN_LOCK_WAIT_MS;
|
|
1202
|
+
while (!locked && Date.now() < deadline) { sleepSync(150); locked = tryAcquireSpawnLock(); }
|
|
1203
|
+
}
|
|
1204
|
+
if (!locked) {
|
|
1205
|
+
console.log("[dsh-remote] 另一个守护正在启动(启动锁被占用),本进程直接退出,避免重复实例。");
|
|
1206
|
+
return;
|
|
1207
|
+
}
|
|
1141
1208
|
const other = readPidFile(WATCHER_PID_FILE);
|
|
1142
1209
|
if (other && other !== process.pid && pidAlive(other)) {
|
|
1143
1210
|
const supervisor = String(process.env.XPC_SERVICE_NAME || "");
|
|
@@ -1157,9 +1224,15 @@ async function runBridge() {
|
|
|
1157
1224
|
writePidFile(WATCHER_PID_FILE, process.pid);
|
|
1158
1225
|
} else {
|
|
1159
1226
|
console.log(`[dsh-remote] 已有一个 bridge 守护在运行(pid=${other}),本进程直接退出,避免重复实例。`);
|
|
1227
|
+
releaseSpawnLock();
|
|
1160
1228
|
return;
|
|
1161
1229
|
}
|
|
1162
1230
|
}
|
|
1231
|
+
// 🔒 关键顺序:**先把自己的 pid 落盘,再释放启动锁**。
|
|
1232
|
+
// 否则"释放锁 → 下面 1373 行才 writePidFile"之间又是一个空窗,第二个实例会读到
|
|
1233
|
+
// "没有别人" → 惊群只是被缩小、并没有消除。1373 行那次写入保持原样(它还负责注册退出清理)。
|
|
1234
|
+
writePidFile(WATCHER_PID_FILE, process.pid);
|
|
1235
|
+
releaseSpawnLock();
|
|
1163
1236
|
// ── 回收「上一任留下的 bridge 子进程」────────────────────────────────────
|
|
1164
1237
|
//
|
|
1165
1238
|
// 🔴 真机 bug(2026-09-23 用户实测):一条微信指令**回两条**。
|
|
@@ -1899,10 +1972,50 @@ function normalizePatchBase(patch) {
|
|
|
1899
1972
|
.trimEnd();
|
|
1900
1973
|
}
|
|
1901
1974
|
|
|
1975
|
+
/**
|
|
1976
|
+
* 插件目录里**必须存在且非空**的文件。缺任何一个都说明这次拷贝是"半个插件"。
|
|
1977
|
+
*
|
|
1978
|
+
* 🔴 为什么必须有这一步(2026-09-26 23:46 真实事故):当时是"先 rmSync 整个 dest、再逐文件 cpSync",
|
|
1979
|
+
* 而 dsh 恰好在这个空窗里启动 → `package.json` 已经声明 bundle,但 `cordis.patch.yml` 还没拷到
|
|
1980
|
+
* → dsh 读 overlay 直接 `ENOENT: no such file or directory` 退出,**整个 GUI 起不来**:
|
|
1981
|
+
*
|
|
1982
|
+
* Error: dsh: failed to read overlay
|
|
1983
|
+
* .../node_modules/dsh-remote-web/cordis.patch.yml: ENOENT
|
|
1984
|
+
*
|
|
1985
|
+
* 拷贝完成时间 23:46:05,dsh 报错时间 23:46:03 —— 差 2 秒。
|
|
1986
|
+
* 所以"拷完必须先自检,再切换"不是优化,是**防止宿主起不来**的硬闸门。
|
|
1987
|
+
*/
|
|
1988
|
+
const PLUGIN_REQUIRED_FILES = ["package.json", "lib/index.js", "lib/client.js", "cordis.patch.yml"];
|
|
1989
|
+
|
|
1990
|
+
/** 校验某个插件目录是否"完整可加载";返回缺失/为空的相对路径数组(空数组 = 通过)。 */
|
|
1991
|
+
function missingPluginFiles(dir) {
|
|
1992
|
+
const missing = [];
|
|
1993
|
+
for (const rel of PLUGIN_REQUIRED_FILES) {
|
|
1994
|
+
try {
|
|
1995
|
+
const st = fs.statSync(path.join(dir, rel));
|
|
1996
|
+
if (!st.isFile() || st.size === 0) missing.push(`${rel}(空)`);
|
|
1997
|
+
} catch {
|
|
1998
|
+
missing.push(rel);
|
|
1999
|
+
}
|
|
2000
|
+
}
|
|
2001
|
+
return missing;
|
|
2002
|
+
}
|
|
2003
|
+
|
|
1902
2004
|
/**
|
|
1903
2005
|
* 把插件整目录拷贝进 profile 的固定子目录并返回该目录。
|
|
1904
2006
|
* 源(本包 packages/${PLUGIN_ID})可能在 npx 临时缓存里,因此必须拷到 profile 内持久化,
|
|
1905
2007
|
* include 用相对路径直接指向拷贝出的入口,不写依赖、不跑任何包管理器。
|
|
2008
|
+
*
|
|
2009
|
+
* 🔴 **原子覆盖(2026-09-26 事故后的硬要求)**:
|
|
2010
|
+
* 1. 先整份拷到同级的 `.staging-<pid>`,**全程不碰 dest** —— dsh 任何时刻读到的
|
|
2011
|
+
* 要么是完整的旧版、要么是完整的新版;
|
|
2012
|
+
* 2. 拷完**校验**必需文件(见 `PLUGIN_REQUIRED_FILES`),不通过就丢弃 staging、抛错、
|
|
2013
|
+
* **保留旧版可用**;
|
|
2014
|
+
* 3. 再 `renameSync` 原子切换(同目录内 rename 是原子操作,空窗只有两次 syscall 之间,
|
|
2015
|
+
* 相比旧实现"整个拷贝过程都处于半空状态"是数量级的改善);
|
|
2016
|
+
* 4. 切换失败 → 立刻把 backup 改回 dest;成功 → 删 backup。
|
|
2017
|
+
*
|
|
2018
|
+
* 另外清掉上次被强杀留下的 `.staging-*` / `.backup-*`,不留垃圾。
|
|
1906
2019
|
*/
|
|
1907
2020
|
function copyPluginIntoProfile(profileDir, pluginDir) {
|
|
1908
2021
|
// 清理历史名残留目录(≤0.4.9 的 dsh-remote-ui-plugin),避免与新版并存
|
|
@@ -1910,8 +2023,9 @@ function copyPluginIntoProfile(profileDir, pluginDir) {
|
|
|
1910
2023
|
try { fs.rmSync(path.join(profileDir, `${id}-plugin`), { recursive: true, force: true }); } catch { /* ignore */ }
|
|
1911
2024
|
}
|
|
1912
2025
|
const dest = path.join(profileDir, PLUGIN_LOCAL_DIR);
|
|
1913
|
-
|
|
1914
|
-
|
|
2026
|
+
const stage = `${dest}.staging-${process.pid}`;
|
|
2027
|
+
const backup = `${dest}.backup-${process.pid}`;
|
|
2028
|
+
const cpOpts = {
|
|
1915
2029
|
recursive: true,
|
|
1916
2030
|
// 注意:插件源(npx 缓存 / npm 全局)本身就位于某个 node_modules 之下,
|
|
1917
2031
|
// 过滤必须基于“相对插件目录”的路径片段,绝不能整条 src 路径包含判断,
|
|
@@ -1921,7 +2035,42 @@ function copyPluginIntoProfile(profileDir, pluginDir) {
|
|
|
1921
2035
|
const rel = path.relative(pluginDir, src);
|
|
1922
2036
|
return !String(rel).split(path.sep).some((seg) => seg === "node_modules" || seg === ".git" || seg === ".DS_Store");
|
|
1923
2037
|
}
|
|
1924
|
-
}
|
|
2038
|
+
};
|
|
2039
|
+
|
|
2040
|
+
// 0) 清掉上次被强杀(或本次同 pid 重入)留下的临时目录
|
|
2041
|
+
for (const d of [stage, backup]) { try { fs.rmSync(d, { recursive: true, force: true }); } catch { /* ignore */ } }
|
|
2042
|
+
|
|
2043
|
+
// 1) 拷到 staging —— **绝不动 dest**
|
|
2044
|
+
try {
|
|
2045
|
+
fs.cpSync(pluginDir, stage, cpOpts);
|
|
2046
|
+
} catch (e) {
|
|
2047
|
+
try { fs.rmSync(stage, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
2048
|
+
throw new Error(`插件拷贝失败(已保留原目录不动):${e && e.message ? e.message : e}`);
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
// 2) 自检:必需文件一个都不能少(这一步就是 23:46 那次事故的闸门)
|
|
2052
|
+
const missing = missingPluginFiles(stage);
|
|
2053
|
+
if (missing.length > 0) {
|
|
2054
|
+
try { fs.rmSync(stage, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
2055
|
+
throw new Error(
|
|
2056
|
+
`插件拷贝不完整,缺少必需文件:${missing.join("、")}(已保留原目录不动,避免宿主读 overlay 失败起不来)`
|
|
2057
|
+
);
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
// 3) 原子切换
|
|
2061
|
+
try {
|
|
2062
|
+
if (fs.existsSync(dest)) fs.renameSync(dest, backup);
|
|
2063
|
+
try {
|
|
2064
|
+
fs.renameSync(stage, dest);
|
|
2065
|
+
} catch (e) {
|
|
2066
|
+
if (fs.existsSync(backup)) { try { fs.renameSync(backup, dest); } catch { /* 尽力回滚 */ } }
|
|
2067
|
+
throw e;
|
|
2068
|
+
}
|
|
2069
|
+
try { fs.rmSync(backup, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
2070
|
+
} catch (e) {
|
|
2071
|
+
try { fs.rmSync(stage, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
2072
|
+
throw new Error(`插件切换失败(旧版仍在原位):${e && e.message ? e.message : e}`);
|
|
2073
|
+
}
|
|
1925
2074
|
return dest;
|
|
1926
2075
|
}
|
|
1927
2076
|
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* ⚠️ 隔离:unset 隔离开关的用例必须同时伪造 HOME 与 PATH(否则会碰真实 HOME 下的 launchd)。
|
|
19
19
|
*/
|
|
20
20
|
import assert from "node:assert/strict";
|
|
21
|
+
import { spawn } from "node:child_process";
|
|
21
22
|
import http from "node:http";
|
|
22
23
|
import { chmod, mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
23
24
|
import { existsSync, readFileSync } from "node:fs";
|
|
@@ -44,7 +45,7 @@ fi
|
|
|
44
45
|
exit 0
|
|
45
46
|
`;
|
|
46
47
|
|
|
47
|
-
async function makeEnv({ fakeLaunchd = true } = {}) {
|
|
48
|
+
async function makeEnv({ fakeLaunchd = true, fakeBridgeJobs = false } = {}) {
|
|
48
49
|
const root = await mkdtemp(path.join(os.tmpdir(), "dsh-acct-dev-"));
|
|
49
50
|
const fakeHome = path.join(root, "home");
|
|
50
51
|
const fakeBin = path.join(root, "bin");
|
|
@@ -65,9 +66,24 @@ async function makeEnv({ fakeLaunchd = true } = {}) {
|
|
|
65
66
|
process.env.DSH_TEST_LAUNCH_LOG = path.join(root, "launchctl.log");
|
|
66
67
|
process.env.DSH_REMOTE_TELEMETRY = "0";
|
|
67
68
|
process.env.DSH_RELAY_SKIP_SERVICE = "1"; // 默认隔离:用例按需 unset
|
|
69
|
+
// 假的 pgrep/ps:让 manualStatus「看得见」一个**旧账号的 bridge 进程**。
|
|
70
|
+
// 🔴 2026-09-25:本文件测的现场就是「旧账号的 bridge 还在跑」(否则面板压根看不到 accountCurrent=false),
|
|
71
|
+
// 而旧实现只伪造了 launchctl、**没伪造 pgrep/ps** —— 于是用例偷偷依赖「开发机上正好有 bridge 在跑」:
|
|
72
|
+
// 开发机有 → 绿;CI 上没有 → 红(`npm test` 在 router 那步就断了,所以这个红一直被挡住)。
|
|
73
|
+
// 真实进程状态不该影响用例结果,所以这里显式伪造。
|
|
74
|
+
let fakeJob = null;
|
|
75
|
+
if (fakeBridgeJobs) {
|
|
76
|
+
fakeJob = spawn(process.execPath, ["-e", "setTimeout(() => {}, 30000)"], { stdio: "ignore" });
|
|
77
|
+
const stub = (body) => `#!/bin/sh\n${body}\n`;
|
|
78
|
+
await writeFile(path.join(fakeBin, "pgrep"), stub(`echo "${fakeJob.pid} node /x/clients/dsh-remote/dsh-bridge.mjs"`));
|
|
79
|
+
await chmod(path.join(fakeBin, "pgrep"), 0o755);
|
|
80
|
+
await writeFile(path.join(fakeBin, "ps"), stub('echo "1"')); // ppid=1 → 不会被当成 launchd 托管
|
|
81
|
+
await chmod(path.join(fakeBin, "ps"), 0o755);
|
|
82
|
+
}
|
|
68
83
|
return {
|
|
69
84
|
root, relayDir, fakeHome, launchLog: path.join(root, "launchctl.log"),
|
|
70
85
|
async restore() {
|
|
86
|
+
try { fakeJob?.kill("SIGKILL"); } catch { /* 已退出 */ }
|
|
71
87
|
for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete process.env[k]; else process.env[k] = v; }
|
|
72
88
|
await rm(root, { recursive: true, force: true });
|
|
73
89
|
},
|
|
@@ -101,7 +117,9 @@ const json = async (p, init) => {
|
|
|
101
117
|
// ─────────── ① 旧账号的 bridge 不得被当成「已连接」 ───────────
|
|
102
118
|
|
|
103
119
|
test("切换账号后:旧账号留下的 online 状态不得再当作注册证据", async () => {
|
|
104
|
-
|
|
120
|
+
// fakeBridgeJobs:这个现场的前提就是「旧账号的 bridge 进程还在跑」——必须伪造出来,
|
|
121
|
+
// 而不是指望跑用例的那台机器上恰好有一个(CI 上没有 → 曾经必红)。
|
|
122
|
+
const env = await makeEnv({ fakeBridgeJobs: true });
|
|
105
123
|
try {
|
|
106
124
|
await writeFile(path.join(env.relayDir, "dsh-setup.mjs"), "// runtime stub");
|
|
107
125
|
// 切账号后的配置:新账号、device_id 已被清空
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
// 📣 公告 / 📶 镜像页耗时 —— **宿主半边**(lib/index.js)的路由契约回归。
|
|
2
|
+
//
|
|
3
|
+
// 为什么单独一个文件:announcements-ui.test.mjs 锁的是浏览器半的行为(徽标/入口/弹窗/不轮询),
|
|
4
|
+
// 这里锁的是 node 半的两条**代理**路由 —— 它们是被浏览器半当"不存在的接口"来对待的
|
|
5
|
+
// (fail closed 的前提是"拿不到就什么都不显示"),所以最要紧的用例是 **失败也不抛**。
|
|
6
|
+
//
|
|
7
|
+
// 两条路由:
|
|
8
|
+
// GET /dsh-remote/announcements → 上游 GET {api}/api/announcements?client=desktop[&install_id=]
|
|
9
|
+
// POST /dsh-remote/announcements/click → 上游 POST {api}/api/announcements/click {id[, install_id]}
|
|
10
|
+
// POST /dsh-remote/telemetry/mirror-load → 入队 mirror_load(ms / at / outcome),非法入参**静默丢弃**
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import http from "node:http";
|
|
13
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
14
|
+
import os from "node:os";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import test from "node:test";
|
|
17
|
+
import { apply, __telemetryInternals as TELEMETRY } from "../lib/index.js";
|
|
18
|
+
|
|
19
|
+
/** 起一个假上游中继(只实现本文件关心的两个端点),把收到的请求记下来。 */
|
|
20
|
+
async function startUpstream(handler) {
|
|
21
|
+
const seen = [];
|
|
22
|
+
const srv = http.createServer((req, res) => {
|
|
23
|
+
let raw = "";
|
|
24
|
+
req.on("data", (c) => { raw += c; });
|
|
25
|
+
req.on("end", () => {
|
|
26
|
+
const entry = { method: req.method, url: req.url, headers: req.headers, body: raw };
|
|
27
|
+
seen.push(entry);
|
|
28
|
+
handler(entry, res);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
await new Promise((r) => srv.listen(0, "127.0.0.1", r));
|
|
32
|
+
return { srv, seen, base: `http://127.0.0.1:${srv.address().port}` };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function boot(relayDir) {
|
|
36
|
+
const routes = [];
|
|
37
|
+
apply({
|
|
38
|
+
webServer: { register(rt) { routes.push(rt); return () => {}; } },
|
|
39
|
+
effect(reg) { return reg(); },
|
|
40
|
+
logger: { info() {}, warn() {} },
|
|
41
|
+
}, { relayDir });
|
|
42
|
+
const host = http.createServer((req, res) => {
|
|
43
|
+
const url = new URL(req.url, "http://x");
|
|
44
|
+
// 与 lib/index.js 的注册语义一致:kind=exact 精确匹配;kind=prefix 匹配自身或子路径。
|
|
45
|
+
// 🔴 注册对象上**只有** {kind, path, handler} —— 没有 method/prefix 字段,
|
|
46
|
+
// 方法判定与子路径分发都在插件自己的 handler 里(见 registerRoutes 的注册循环)。
|
|
47
|
+
// 2026-09-27 修:本文件原来按 `route.prefix` / `route.method` 去匹配(那两个字段根本不存在)
|
|
48
|
+
// ⇒ methodOk 恒为 false ⇒ 所有请求都落到兜底 404、body 为空 ⇒ 真路由的用例集体报
|
|
49
|
+
// "Unexpected end of JSON input"(假红,不是实现的问题)。照 community-qr-ui.test.mjs 的写法对齐。
|
|
50
|
+
const rt = routes.find((r) => (r.kind === "prefix"
|
|
51
|
+
? url.pathname === r.path || url.pathname.startsWith(r.path + "/")
|
|
52
|
+
: url.pathname === r.path));
|
|
53
|
+
(rt ? rt.handler : ((_r, rs) => { rs.writeHead(404); rs.end(); }))(req, res);
|
|
54
|
+
});
|
|
55
|
+
return host;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// 注:本文件**不需要**自己管理 DSH_RELAY_SKIP_SERVICE —— `npm run test:plugin` 已给整个套件设了 `=1`。
|
|
59
|
+
// 曾经这里有个 withEnv() 提前 delete 它(既无人调用,又会被 setup-output.test.mjs 的静态护栏判为
|
|
60
|
+
// 「unset 开关却没伪造 HOME」)。删掉它,隔离与护栏两边都干净。
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 从假上游收到的请求里**只挑公告那一条**。
|
|
64
|
+
* 为什么不能写 `up.seen[0]`:插件装载时还会对同一个 api_url 发起旁路请求
|
|
65
|
+
* (harness 会话代持 scheduleHarnessMint、账号 device-login 等),它们同样落进 seen,
|
|
66
|
+
* 谁先到不确定 —— 按下标取会变成**偶发假红**(2026-09-27 实测踩到一次)。
|
|
67
|
+
*/
|
|
68
|
+
const annReq = (up) => up.seen.find((e) => String(e.url || "").startsWith("/api/announcements"));
|
|
69
|
+
|
|
70
|
+
const writeCfg = (relayDir, apiUrl, extra = {}) => writeFile(path.join(relayDir, ".dsh-config.json"), JSON.stringify({
|
|
71
|
+
device_id: "dev-ann-1", api_url: apiUrl, ...extra,
|
|
72
|
+
}));
|
|
73
|
+
|
|
74
|
+
// ───────────────────────────── GET 代理 ─────────────────────────────
|
|
75
|
+
|
|
76
|
+
test("GET /dsh-remote/announcements:显式带 client=desktop,未登录时带本机 install_id(复用 device_id,不新造 ID)", async () => {
|
|
77
|
+
const up = await startUpstream((_e, res) => { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify({ ok: true, bubble_enabled: true, items: [], unread_count: 0, popup: null })); });
|
|
78
|
+
const relayDir = await mkdtemp(path.join(os.tmpdir(), "dsh-ann-"));
|
|
79
|
+
process.env.DSH_RELAY_SKIP_SERVICE = "1";
|
|
80
|
+
try {
|
|
81
|
+
await writeCfg(relayDir, up.base);
|
|
82
|
+
const host = boot(relayDir);
|
|
83
|
+
await new Promise((r) => host.listen(0, "127.0.0.1", r));
|
|
84
|
+
const base = `http://127.0.0.1:${host.address().port}`;
|
|
85
|
+
const r = await (await fetch(`${base}/dsh-remote/announcements?client=desktop`)).json();
|
|
86
|
+
assert.equal(r.ok, true);
|
|
87
|
+
|
|
88
|
+
const got = annReq(up);
|
|
89
|
+
assert.ok(got, "必须真的打到上游 /api/announcements");
|
|
90
|
+
assert.equal(got.method, "GET");
|
|
91
|
+
const u = new URL(got.url, "http://x");
|
|
92
|
+
assert.equal(u.pathname, "/api/announcements", "必须打到上游 /api/announcements");
|
|
93
|
+
assert.equal(u.searchParams.get("client"), "desktop", "必须显式声明桌面端(926 迭代 §1)");
|
|
94
|
+
assert.equal(u.searchParams.get("install_id"), "dev-ann-1", "未登录时用本机既有 device_id 做维度");
|
|
95
|
+
assert.equal(got.headers.authorization, undefined, "没有账号时不得凭空造 Bearer");
|
|
96
|
+
host.close();
|
|
97
|
+
} finally {
|
|
98
|
+
// 恢复套件级隔离(**不要 delete**):package.json 的 test:plugin 就是把 DSH_RELAY_SKIP_SERVICE=1
|
|
99
|
+
// 设给整个套件的;这里 delete 会把它一起删掉 → 本文件之后的用例失去隔离,真的去后台
|
|
100
|
+
// `npx @mrrisegao/dsh-remote` 装运行环境(2026-09-27 实测:日志里刷出一串
|
|
101
|
+
// 「检测到缺少桌面运行环境,已在后台自动安装」,既慢又有真实副作用)。
|
|
102
|
+
// 本文件只测路由,不需要走真实分支。
|
|
103
|
+
process.env.DSH_RELAY_SKIP_SERVICE = "1";
|
|
104
|
+
up.srv.close();
|
|
105
|
+
await rm(relayDir, { recursive: true, force: true });
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("GET:浏览器已带 Bearer → 透传,且**不再**带 install_id(两种身份维度绝不能混用)", async () => {
|
|
110
|
+
const up = await startUpstream((_e, res) => { res.writeHead(200, { "content-type": "application/json" }); res.end("{\"ok\":true}"); });
|
|
111
|
+
const relayDir = await mkdtemp(path.join(os.tmpdir(), "dsh-ann-"));
|
|
112
|
+
process.env.DSH_RELAY_SKIP_SERVICE = "1";
|
|
113
|
+
try {
|
|
114
|
+
await writeCfg(relayDir, up.base, { phone: "13800000000", password: "pw" });
|
|
115
|
+
const host = boot(relayDir);
|
|
116
|
+
await new Promise((r) => host.listen(0, "127.0.0.1", r));
|
|
117
|
+
const base = `http://127.0.0.1:${host.address().port}`;
|
|
118
|
+
await fetch(`${base}/dsh-remote/announcements?client=desktop`, { headers: { authorization: "Bearer browser-jwt" } });
|
|
119
|
+
const got = annReq(up);
|
|
120
|
+
assert.ok(got, "必须真的打到上游 /api/announcements");
|
|
121
|
+
assert.equal(got.headers.authorization, "Bearer browser-jwt", "浏览器带上的身份必须原样透传");
|
|
122
|
+
const u = new URL(got.url, "http://x");
|
|
123
|
+
assert.equal(u.searchParams.get("install_id"), null,
|
|
124
|
+
"有 user 维度时不得再带 install_id —— 唯一索引会把同一人两台机器算成两行,UV 翻倍");
|
|
125
|
+
host.close();
|
|
126
|
+
} finally {
|
|
127
|
+
// 恢复套件级隔离(**不要 delete**):package.json 的 test:plugin 就是把 DSH_RELAY_SKIP_SERVICE=1
|
|
128
|
+
// 设给整个套件的;这里 delete 会把它一起删掉 → 本文件之后的用例失去隔离,真的去后台
|
|
129
|
+
// `npx @mrrisegao/dsh-remote` 装运行环境(2026-09-27 实测:日志里刷出一串
|
|
130
|
+
// 「检测到缺少桌面运行环境,已在后台自动安装」,既慢又有真实副作用)。
|
|
131
|
+
// 本文件只测路由,不需要走真实分支。
|
|
132
|
+
process.env.DSH_RELAY_SKIP_SERVICE = "1";
|
|
133
|
+
up.srv.close();
|
|
134
|
+
await rm(relayDir, { recursive: true, force: true });
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("GET:上游 404(老服务端)→ 原样透传 404(浏览器半据此 fail closed),路由本身不抛", async () => {
|
|
139
|
+
const up = await startUpstream((_e, res) => { res.writeHead(404, { "content-type": "application/json" }); res.end("{\"error\":{\"code\":\"not_found\"}}"); });
|
|
140
|
+
const relayDir = await mkdtemp(path.join(os.tmpdir(), "dsh-ann-"));
|
|
141
|
+
process.env.DSH_RELAY_SKIP_SERVICE = "1";
|
|
142
|
+
try {
|
|
143
|
+
await writeCfg(relayDir, up.base);
|
|
144
|
+
const host = boot(relayDir);
|
|
145
|
+
await new Promise((r) => host.listen(0, "127.0.0.1", r));
|
|
146
|
+
const base = `http://127.0.0.1:${host.address().port}`;
|
|
147
|
+
const res = await fetch(`${base}/dsh-remote/announcements?client=desktop`);
|
|
148
|
+
assert.equal(res.status, 404);
|
|
149
|
+
host.close();
|
|
150
|
+
} finally {
|
|
151
|
+
// 恢复套件级隔离(**不要 delete**):package.json 的 test:plugin 就是把 DSH_RELAY_SKIP_SERVICE=1
|
|
152
|
+
// 设给整个套件的;这里 delete 会把它一起删掉 → 本文件之后的用例失去隔离,真的去后台
|
|
153
|
+
// `npx @mrrisegao/dsh-remote` 装运行环境(2026-09-27 实测:日志里刷出一串
|
|
154
|
+
// 「检测到缺少桌面运行环境,已在后台自动安装」,既慢又有真实副作用)。
|
|
155
|
+
// 本文件只测路由,不需要走真实分支。
|
|
156
|
+
process.env.DSH_RELAY_SKIP_SERVICE = "1";
|
|
157
|
+
up.srv.close();
|
|
158
|
+
await rm(relayDir, { recursive: true, force: true });
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("GET:上游不可达 → 502 + {ok:false}(软着陆:绝不抛成 500 堆栈)", async () => {
|
|
163
|
+
const relayDir = await mkdtemp(path.join(os.tmpdir(), "dsh-ann-"));
|
|
164
|
+
process.env.DSH_RELAY_SKIP_SERVICE = "1";
|
|
165
|
+
try {
|
|
166
|
+
// 指向一个已经关掉的端口:连接必然失败
|
|
167
|
+
await writeCfg(relayDir, "http://127.0.0.1:1");
|
|
168
|
+
const host = boot(relayDir);
|
|
169
|
+
await new Promise((r) => host.listen(0, "127.0.0.1", r));
|
|
170
|
+
const base = `http://127.0.0.1:${host.address().port}`;
|
|
171
|
+
const res = await fetch(`${base}/dsh-remote/announcements?client=desktop`);
|
|
172
|
+
assert.equal(res.status, 502, "不可达必须是 502(不是 500、不是抛异常)");
|
|
173
|
+
const body = await res.json();
|
|
174
|
+
assert.equal(body.ok, false);
|
|
175
|
+
assert.match(String(body.error), /公告服务不可达/);
|
|
176
|
+
host.close();
|
|
177
|
+
} finally {
|
|
178
|
+
// 恢复套件级隔离(**不要 delete**):package.json 的 test:plugin 就是把 DSH_RELAY_SKIP_SERVICE=1
|
|
179
|
+
// 设给整个套件的;这里 delete 会把它一起删掉 → 本文件之后的用例失去隔离,真的去后台
|
|
180
|
+
// `npx @mrrisegao/dsh-remote` 装运行环境(2026-09-27 实测:日志里刷出一串
|
|
181
|
+
// 「检测到缺少桌面运行环境,已在后台自动安装」,既慢又有真实副作用)。
|
|
182
|
+
// 本文件只测路由,不需要走真实分支。
|
|
183
|
+
process.env.DSH_RELAY_SKIP_SERVICE = "1";
|
|
184
|
+
await rm(relayDir, { recursive: true, force: true });
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// ───────────────────────────── click 代理 ─────────────────────────────
|
|
189
|
+
|
|
190
|
+
test("POST /dsh-remote/announcements/click:转发 {id} 到上游 /api/announcements/click,并补 install_id", async () => {
|
|
191
|
+
const up = await startUpstream((_e, res) => { res.writeHead(200, { "content-type": "application/json" }); res.end("{\"ok\":true}"); });
|
|
192
|
+
const relayDir = await mkdtemp(path.join(os.tmpdir(), "dsh-ann-"));
|
|
193
|
+
process.env.DSH_RELAY_SKIP_SERVICE = "1";
|
|
194
|
+
try {
|
|
195
|
+
await writeCfg(relayDir, up.base);
|
|
196
|
+
const host = boot(relayDir);
|
|
197
|
+
await new Promise((r) => host.listen(0, "127.0.0.1", r));
|
|
198
|
+
const base = `http://127.0.0.1:${host.address().port}`;
|
|
199
|
+
const r = await (await fetch(`${base}/dsh-remote/announcements/click`, {
|
|
200
|
+
method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ id: 7 }),
|
|
201
|
+
})).json();
|
|
202
|
+
assert.equal(r.ok, true);
|
|
203
|
+
|
|
204
|
+
const got = annReq(up);
|
|
205
|
+
assert.ok(got, "必须真的打到上游 /api/announcements/click");
|
|
206
|
+
assert.equal(got.method, "POST");
|
|
207
|
+
assert.equal(new URL(got.url, "http://x").pathname, "/api/announcements/click");
|
|
208
|
+
const sent = JSON.parse(got.body);
|
|
209
|
+
assert.equal(sent.id, 7, "id 必须原样转发");
|
|
210
|
+
assert.equal(sent.install_id, "dev-ann-1", "未登录维度要补 install_id,否则点击记不到这台机器名下");
|
|
211
|
+
host.close();
|
|
212
|
+
} finally {
|
|
213
|
+
// 恢复套件级隔离(**不要 delete**):package.json 的 test:plugin 就是把 DSH_RELAY_SKIP_SERVICE=1
|
|
214
|
+
// 设给整个套件的;这里 delete 会把它一起删掉 → 本文件之后的用例失去隔离,真的去后台
|
|
215
|
+
// `npx @mrrisegao/dsh-remote` 装运行环境(2026-09-27 实测:日志里刷出一串
|
|
216
|
+
// 「检测到缺少桌面运行环境,已在后台自动安装」,既慢又有真实副作用)。
|
|
217
|
+
// 本文件只测路由,不需要走真实分支。
|
|
218
|
+
process.env.DSH_RELAY_SKIP_SERVICE = "1";
|
|
219
|
+
up.srv.close();
|
|
220
|
+
await rm(relayDir, { recursive: true, force: true });
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("未知子路径 → 404(不自造接口,也不把奇怪路径代理到上游)", async () => {
|
|
225
|
+
const up = await startUpstream((_e, res) => { res.writeHead(200); res.end("{}"); });
|
|
226
|
+
const relayDir = await mkdtemp(path.join(os.tmpdir(), "dsh-ann-"));
|
|
227
|
+
process.env.DSH_RELAY_SKIP_SERVICE = "1";
|
|
228
|
+
try {
|
|
229
|
+
await writeCfg(relayDir, up.base);
|
|
230
|
+
const host = boot(relayDir);
|
|
231
|
+
await new Promise((r) => host.listen(0, "127.0.0.1", r));
|
|
232
|
+
const base = `http://127.0.0.1:${host.address().port}`;
|
|
233
|
+
// 契约里没有 /read —— 老客户端若按错的路径打过来,必须明确 404(而不是打到上游去)
|
|
234
|
+
const res = await fetch(`${base}/dsh-remote/announcements/read`, { method: "POST", body: "{}" });
|
|
235
|
+
assert.equal(res.status, 404);
|
|
236
|
+
assert.equal(up.seen.length, 0, "未命中子路径时不得触碰上游");
|
|
237
|
+
host.close();
|
|
238
|
+
} finally {
|
|
239
|
+
// 恢复套件级隔离(**不要 delete**):package.json 的 test:plugin 就是把 DSH_RELAY_SKIP_SERVICE=1
|
|
240
|
+
// 设给整个套件的;这里 delete 会把它一起删掉 → 本文件之后的用例失去隔离,真的去后台
|
|
241
|
+
// `npx @mrrisegao/dsh-remote` 装运行环境(2026-09-27 实测:日志里刷出一串
|
|
242
|
+
// 「检测到缺少桌面运行环境,已在后台自动安装」,既慢又有真实副作用)。
|
|
243
|
+
// 本文件只测路由,不需要走真实分支。
|
|
244
|
+
process.env.DSH_RELAY_SKIP_SERVICE = "1";
|
|
245
|
+
up.srv.close();
|
|
246
|
+
await rm(relayDir, { recursive: true, force: true });
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// ───────────────────────────── mirror_load 事件 ─────────────────────────────
|
|
251
|
+
|
|
252
|
+
test("mirror_load 事件形状:ms / at / outcome 三个字段,且越界由 eventOf 兜底钳制", () => {
|
|
253
|
+
const ev = TELEMETRY.eventOf("mirror_load", { ms: 1234, at: 1758500000000, outcome: "slow" });
|
|
254
|
+
assert.ok(ev, "mirror_load 必须在白名单里(否则事件被静默丢弃)");
|
|
255
|
+
assert.equal(ev.name, "mirror_load");
|
|
256
|
+
assert.equal(ev.ms, 1234);
|
|
257
|
+
assert.equal(ev.at, 1758500000000, "at 必须是客户端事件时刻(覆盖默认的 Date.now)");
|
|
258
|
+
assert.equal(ev.outcome, "slow");
|
|
259
|
+
// 缺 at → 保留默认时刻(绝不发 0,0 会被读成 1970 年)
|
|
260
|
+
const noAt = TELEMETRY.eventOf("mirror_load", { ms: 10 });
|
|
261
|
+
assert.ok(noAt.at > 0, "没给 at 时必须退回事件构造时刻,绝不发 0");
|
|
262
|
+
assert.equal(noAt.outcome, "ok", "outcome 只认 slow,其余一律 ok");
|
|
263
|
+
// 越界 / 非法 ms:不写入该字段(路由层已先挡掉,这是第二道防线)
|
|
264
|
+
const bad = TELEMETRY.eventOf("mirror_load", { ms: "abc" });
|
|
265
|
+
assert.equal("ms" in bad, false, "非法 ms 不得进 payload");
|
|
266
|
+
const huge = TELEMETRY.eventOf("mirror_load", { ms: 10 ** 9 });
|
|
267
|
+
assert.equal(huge.ms, 600000, "越界 ms 钳到契约上限");
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("POST /dsh-remote/telemetry/mirror-load:合法/非法入参都回 200 {ok:true},非法**静默丢弃**", async () => {
|
|
271
|
+
const relayDir = await mkdtemp(path.join(os.tmpdir(), "dsh-ann-"));
|
|
272
|
+
process.env.DSH_RELAY_SKIP_SERVICE = "1";
|
|
273
|
+
try {
|
|
274
|
+
const host = boot(relayDir);
|
|
275
|
+
await new Promise((r) => host.listen(0, "127.0.0.1", r));
|
|
276
|
+
const base = `http://127.0.0.1:${host.address().port}`;
|
|
277
|
+
const post = async (body) => {
|
|
278
|
+
const res = await fetch(`${base}/dsh-remote/telemetry/mirror-load`, {
|
|
279
|
+
method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body),
|
|
280
|
+
});
|
|
281
|
+
return { status: res.status, body: await res.json() };
|
|
282
|
+
};
|
|
283
|
+
// 隔离开关(DSH_RELAY_SKIP_SERVICE=1)下遥测整体关闭 → 这里看到的就是"路由**永远**回 200",
|
|
284
|
+
// 而不是 4xx/5xx —— 手机镜像页的加载路径绝不能被一条统计请求打扰。
|
|
285
|
+
for (const payload of [
|
|
286
|
+
{ ms: 800, at: Date.now(), outcome: "ok" },
|
|
287
|
+
{ ms: -1, at: Date.now() }, // 负数
|
|
288
|
+
{ ms: 600001, at: Date.now() }, // 超上限
|
|
289
|
+
{ ms: "abc" }, // 非数字
|
|
290
|
+
{}, // 缺字段
|
|
291
|
+
]) {
|
|
292
|
+
const r = await post(payload);
|
|
293
|
+
assert.equal(r.status, 200, "任何入参都必须是 200 " + JSON.stringify(payload));
|
|
294
|
+
assert.equal(r.body.ok, true);
|
|
295
|
+
}
|
|
296
|
+
// 极端:连 JSON 都不是 → 仍然 200(绝不因为一条统计把页面打成错误)
|
|
297
|
+
const raw = await fetch(`${base}/dsh-remote/telemetry/mirror-load`, { method: "POST", body: "not-json" });
|
|
298
|
+
assert.equal(raw.status, 200);
|
|
299
|
+
host.close();
|
|
300
|
+
} finally {
|
|
301
|
+
// 恢复套件级隔离(**不要 delete**):package.json 的 test:plugin 就是把 DSH_RELAY_SKIP_SERVICE=1
|
|
302
|
+
// 设给整个套件的;这里 delete 会把它一起删掉 → 本文件之后的用例失去隔离,真的去后台
|
|
303
|
+
// `npx @mrrisegao/dsh-remote` 装运行环境(2026-09-27 实测:日志里刷出一串
|
|
304
|
+
// 「检测到缺少桌面运行环境,已在后台自动安装」,既慢又有真实副作用)。
|
|
305
|
+
// 本文件只测路由,不需要走真实分支。
|
|
306
|
+
process.env.DSH_RELAY_SKIP_SERVICE = "1";
|
|
307
|
+
TELEMETRY.reset(relayDir);
|
|
308
|
+
await rm(relayDir, { recursive: true, force: true });
|
|
309
|
+
}
|
|
310
|
+
});
|