@mrrisega/dsh-remote 0.7.1 → 0.7.2-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/dsh-bridge.mjs +16 -0
- package/clients/dsh-remote/mirror-diag-script.js +154 -0
- package/clients/dsh-remote/mirror-diag.mjs +106 -0
- package/clients/dsh-remote/mobile-adapter.mjs +236 -22
- package/clients/dsh-remote/test/bridge-html-inject.test.mjs +46 -10
- package/clients/dsh-remote/test/bridge-upstream-refresh.test.mjs +40 -0
- package/clients/dsh-remote/test/mirror-diag.test.mjs +82 -0
- package/clients/dsh-remote/test/mobile-adapter-guards.test.mjs +31 -11
- package/clients/dsh-remote/test/mobile-adapter-load-watch.test.mjs +408 -0
- package/package.json +1 -1
- package/packages/dsh-remote-web/lib/client.js +152 -155
- package/packages/dsh-remote-web/lib/index.js +396 -14
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +16 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mirror-diag-script.js +154 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mirror-diag.mjs +106 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +236 -22
- package/packages/dsh-remote-web/test/announcements-ui.test.mjs +6 -3
- package/packages/dsh-remote-web/test/desktop-compat.test.mjs +12 -6
- package/packages/dsh-remote-web/test/remote-access-ui.test.mjs +116 -3
- package/packages/dsh-remote-web/test/repair-backup.test.mjs +599 -0
|
@@ -70,16 +70,50 @@ const decode = (reply) => {
|
|
|
70
70
|
};
|
|
71
71
|
|
|
72
72
|
test("集成:官方特征 html 经 doHttp 回传时注入适配层", async () => {
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
73
|
+
// 2026-10-17 起还有一份**独立的**故障采集器注入(与 mobile-adapter 无关);
|
|
74
|
+
// 本用例只验适配层,用 DSH_MIRROR_DIAG=0 把它关掉,保证这里断言的是"仅 splice、原文不动"。
|
|
75
|
+
process.env.DSH_MIRROR_DIAG = "0";
|
|
76
|
+
try {
|
|
77
|
+
const reply = await request({ id: "h1", method: "GET", path: "/page", headers: { "accept-encoding": "gzip", "user-agent": "phone" } });
|
|
78
|
+
assert.equal(reply.status, 200);
|
|
79
|
+
const text = decode(reply);
|
|
80
|
+
assert.ok(text.includes('data-dsh-mobile-adapter'), "应注入 style/script");
|
|
81
|
+
const headIdx = text.toLowerCase().lastIndexOf("</head>");
|
|
82
|
+
assert.ok(text.indexOf("dsh-mobile-adapter-css") < headIdx, "注入块应位于 </head> 前");
|
|
83
|
+
// 原文其余部分保持(仅 splice)
|
|
84
|
+
const oi = OFFICIAL_HTML.toLowerCase().lastIndexOf("</head>");
|
|
85
|
+
assert.ok(text.startsWith(OFFICIAL_HTML.slice(0, oi)));
|
|
86
|
+
assert.ok(text.endsWith(OFFICIAL_HTML.slice(oi)));
|
|
87
|
+
} finally {
|
|
88
|
+
delete process.env.DSH_MIRROR_DIAG;
|
|
89
|
+
delete process.env.DSH_MOBILE_ADAPTER;
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("★ 集成:故障采集器**无条件**注入(不依赖 e2ee,也不依赖 mobile-adapter 开关)", async () => {
|
|
94
|
+
// 出问题的用户往往没开 E2EE、也可能被关了适配层 —— 采集器必须仍然在场,否则又变成"猜"。
|
|
95
|
+
process.env.DSH_MOBILE_ADAPTER = "0";
|
|
96
|
+
try {
|
|
97
|
+
const reply = await request({ id: "h1b", method: "GET", path: "/page", headers: { "user-agent": "phone" } });
|
|
98
|
+
const text = decode(reply);
|
|
99
|
+
assert.ok(text.includes("data-dsh-mirror-diag"), "mobile-adapter 关掉时采集器仍要注入");
|
|
100
|
+
assert.ok(text.indexOf("data-dsh-mirror-diag") < text.indexOf("__ModuleLoader__"), "必须在页面自身脚本之前");
|
|
101
|
+
assert.ok(!text.includes("data-dsh-mobile-adapter"), "适配层确实关着");
|
|
102
|
+
} finally {
|
|
103
|
+
delete process.env.DSH_MOBILE_ADAPTER;
|
|
104
|
+
}
|
|
105
|
+
// 逃生口:DSH_MIRROR_DIAG=0 → 一个字节都不注入,严格原样
|
|
106
|
+
process.env.DSH_MIRROR_DIAG = "0";
|
|
107
|
+
process.env.DSH_MOBILE_ADAPTER = "0"; // 适配层也要关,否则"原样"的基准不是 OFFICIAL_HTML
|
|
108
|
+
try {
|
|
109
|
+
const reply = await request({ id: "h1c", method: "GET", path: "/page", headers: { "user-agent": "phone" } });
|
|
110
|
+
const text = decode(reply);
|
|
111
|
+
assert.equal(text, OFFICIAL_HTML);
|
|
112
|
+
assert.ok(!text.includes("data-dsh-mirror-diag"));
|
|
113
|
+
} finally {
|
|
114
|
+
delete process.env.DSH_MIRROR_DIAG;
|
|
115
|
+
delete process.env.DSH_MOBILE_ADAPTER;
|
|
116
|
+
}
|
|
83
117
|
});
|
|
84
118
|
|
|
85
119
|
test("集成:注入在 gzip 前发生,回包仍可 gzip 解压且含注入块", async () => {
|
|
@@ -91,6 +125,7 @@ test("集成:注入在 gzip 前发生,回包仍可 gzip 解压且含注入块",
|
|
|
91
125
|
|
|
92
126
|
test("集成:DSH_MOBILE_ADAPTER=0 关闭注入,原样回传", async () => {
|
|
93
127
|
process.env.DSH_MOBILE_ADAPTER = "0";
|
|
128
|
+
process.env.DSH_MIRROR_DIAG = "0"; // 两个注入器都关,才叫"原样"
|
|
94
129
|
try {
|
|
95
130
|
const reply = await request({ id: "h3", method: "GET", path: "/page", headers: { "user-agent": "phone" } });
|
|
96
131
|
const text = decode(reply);
|
|
@@ -98,6 +133,7 @@ test("集成:DSH_MOBILE_ADAPTER=0 关闭注入,原样回传", async () => {
|
|
|
98
133
|
assert.equal(text, OFFICIAL_HTML);
|
|
99
134
|
} finally {
|
|
100
135
|
delete process.env.DSH_MOBILE_ADAPTER;
|
|
136
|
+
delete process.env.DSH_MIRROR_DIAG;
|
|
101
137
|
}
|
|
102
138
|
});
|
|
103
139
|
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 上游端口漂移自愈(2026-10-17)。
|
|
4
|
+
*
|
|
5
|
+
* 生产日志实证:
|
|
6
|
+
* WS 升级被拒: reason=upstream_ws_open_failed status=502 msg=connect ECONNREFUSED 127.0.0.1:19387
|
|
7
|
+
* 桌面端 App 重启会换本地端口,而 bridge 缓存了旧上游 → 镜像页 502/白屏。
|
|
8
|
+
* HTTP 失败路径早就会 refreshUpstream,**WS 失败路径之前漏了** —— 而镜像页常先开 WS。
|
|
9
|
+
*
|
|
10
|
+
* 这个用例守住:两条失败路径都必须触发重新发现(否则又回到"只能重启 bridge")。
|
|
11
|
+
*/
|
|
12
|
+
import test from "node:test";
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
import { readFileSync } from "node:fs";
|
|
15
|
+
|
|
16
|
+
const SRC = readFileSync(new URL("../dsh-bridge.mjs", import.meta.url), "utf8");
|
|
17
|
+
|
|
18
|
+
test("★ WS 打开失败必须触发上游重新发现(不只是回 502)", () => {
|
|
19
|
+
// 找到 ws.on("error" ...) 的未建立分支
|
|
20
|
+
const i = SRC.indexOf('ws.on("error"');
|
|
21
|
+
assert.ok(i > 0, "必须有 ws error 处理");
|
|
22
|
+
const branch = SRC.slice(i, i + 1200);
|
|
23
|
+
assert.match(branch, /!session\.opened/, "只对未建立的会话做 502");
|
|
24
|
+
assert.match(branch, /refreshUpstream\(/, "★ 必须触发重新发现,否则端口变了会一直 502");
|
|
25
|
+
assert.match(branch, /code: 502/, "行为不变:本次仍然回 502");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("★ HTTP 失败路径也要有重新发现(既有能力不得回退)", () => {
|
|
29
|
+
const hits = SRC.match(/refreshUpstream\(/g) || [];
|
|
30
|
+
assert.ok(hits.length >= 3, `至少 HTTP/WS/启动 三处调用,实际 ${hits.length}`);
|
|
31
|
+
assert.match(SRC, /上游请求失败/, "HTTP 失败路径的理由文案应在");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("★ 重新发现必须有冷却(失败连成片时不能每个请求都 lsof/pgrep)", () => {
|
|
35
|
+
assert.match(SRC, /UPSTREAM_REFRESH_COOLDOWN_MS/);
|
|
36
|
+
const m = SRC.match(/UPSTREAM_REFRESH_COOLDOWN_MS\s*=\s*([\d_]+)/);
|
|
37
|
+
assert.ok(m, "必须定义冷却常量");
|
|
38
|
+
const ms = Number(m[1].replace(/_/g, ""));
|
|
39
|
+
assert.ok(ms >= 5000 && ms <= 60000, `冷却应在 5–60s(实际 ${ms}ms)`);
|
|
40
|
+
});
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* mirror-diag.mjs —— 镜像页故障采集器的注入契约(防回归)。
|
|
4
|
+
*
|
|
5
|
+
* 这个文件守的是"下次故障能不能带证据回来",本身不修任何功能,所以更不能坏:
|
|
6
|
+
* ① 注入位置必须在 </head> 前(否则文档已开始执行,抓不到启动错误);
|
|
7
|
+
* ② 幂等:重复注入会把同一份脚本塞两遍(每次请求/每次重试都塞);
|
|
8
|
+
* ③ 门要准:只注入官方 dsh web 文档(text/html + 宿主特征),不能污染其它响应;
|
|
9
|
+
* ④ 开关必须真的关:DSH_MIRROR_DIAG=0 → 一个字节都不注入;
|
|
10
|
+
* ⑤ 注入体不得含 </script>(会把整页截断)。
|
|
11
|
+
*
|
|
12
|
+
* 用法:node --test test/mirror-diag.test.mjs
|
|
13
|
+
*/
|
|
14
|
+
import test from "node:test";
|
|
15
|
+
import assert from "node:assert/strict";
|
|
16
|
+
import {
|
|
17
|
+
injectMirrorDiag, maybeInjectMirrorDiag, shouldInjectMirrorDiag,
|
|
18
|
+
mirrorDiagEnabled, DIAG_ID, DIAG_SCRIPT_PATH
|
|
19
|
+
} from "../mirror-diag.mjs";
|
|
20
|
+
import fs from "node:fs";
|
|
21
|
+
|
|
22
|
+
const HTML = `<!doctype html><html><head><title>DeepSeek Harness</title>
|
|
23
|
+
<script type="module" src="./assets/index.js"></script></head><body><div id="root"></div></body></html>`;
|
|
24
|
+
|
|
25
|
+
test("★ 注入到 </head> 之前,且带唯一标记(幂等可识别)", () => {
|
|
26
|
+
const out = injectMirrorDiag(HTML);
|
|
27
|
+
assert.equal(out.injected, true);
|
|
28
|
+
const i = out.html.indexOf(`data-${DIAG_ID}`);
|
|
29
|
+
assert.ok(i > 0, "必须带 data- 标记");
|
|
30
|
+
assert.ok(i < out.html.indexOf("</head>"), "必须在 </head> 之前");
|
|
31
|
+
assert.ok(i < out.html.indexOf("./assets/index.js"), "必须早于页面自身脚本(才能抓到启动期错误)");
|
|
32
|
+
assert.ok(i < out.html.indexOf("<title>"), "必须紧跟 <head> 开头(越早越能抓到错误)");
|
|
33
|
+
// 原始内容一个不丢
|
|
34
|
+
assert.ok(out.html.includes('<div id="root"></div>'));
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("★ 幂等:已注入过就不再注入(防止重复执行/重复上报)", () => {
|
|
38
|
+
const once = injectMirrorDiag(HTML);
|
|
39
|
+
const twice = injectMirrorDiag(once.html);
|
|
40
|
+
assert.equal(twice.injected, false);
|
|
41
|
+
assert.equal(twice.html, once.html);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("★ 门要准:非 HTML / 非官方文档 / 已注入 → 一律不注入", () => {
|
|
45
|
+
assert.equal(shouldInjectMirrorDiag({ contentType: "application/javascript", html: HTML }), false, "JS 不注入");
|
|
46
|
+
assert.equal(shouldInjectMirrorDiag({ contentType: "text/html", html: "<html><head></head><body>hi</body></html>" }), false, "非官方文档不注入");
|
|
47
|
+
assert.equal(shouldInjectMirrorDiag({ contentType: "text/html", html: "<html><body>无 head</body></html>" }), false, "无 </head> 不注入");
|
|
48
|
+
const injected = injectMirrorDiag(HTML).html;
|
|
49
|
+
assert.equal(shouldInjectMirrorDiag({ contentType: "text/html", html: injected }), false, "已注入要跳过");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("★ 开关真能关:DSH_MIRROR_DIAG=0 → 不注入(逃生口)", () => {
|
|
53
|
+
const old = process.env.DSH_MIRROR_DIAG;
|
|
54
|
+
try {
|
|
55
|
+
process.env.DSH_MIRROR_DIAG = "0";
|
|
56
|
+
assert.equal(mirrorDiagEnabled(), false);
|
|
57
|
+
const r = maybeInjectMirrorDiag({ buf: Buffer.from(HTML), contentType: "text/html" });
|
|
58
|
+
assert.equal(r.injected, false);
|
|
59
|
+
} finally {
|
|
60
|
+
if (old === undefined) delete process.env.DSH_MIRROR_DIAG; else process.env.DSH_MIRROR_DIAG = old;
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("★ Buffer 入口:注入后仍是合法 UTF-8,且字节数增加", () => {
|
|
65
|
+
const buf = Buffer.from(HTML);
|
|
66
|
+
const r = maybeInjectMirrorDiag({ buf, contentType: "text/html; charset=utf-8" });
|
|
67
|
+
assert.equal(r.injected, true);
|
|
68
|
+
assert.ok(r.buf.length > buf.length, "注入后应变大");
|
|
69
|
+
const text = r.buf.toString("utf8");
|
|
70
|
+
assert.ok(text.includes("</head>") && text.includes("dsh-mirror-diag"));
|
|
71
|
+
assert.ok(!text.includes("\ufffd"), "不得出现替换字符(编码破坏)");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("★ 采集器源码自身:不含 </script>、不写 DOM、只在失败时上报", () => {
|
|
75
|
+
const src = fs.readFileSync(DIAG_SCRIPT_PATH, "utf8");
|
|
76
|
+
assert.ok(src.length > 500, "采集器不能是空壳");
|
|
77
|
+
assert.ok(!/<\/script/i.test(src), "含 </script> 会把宿主文档截断");
|
|
78
|
+
assert.ok(!/document\.body\.appendChild|innerHTML\s*=/.test(src), "采集器不得改动页面 DOM");
|
|
79
|
+
assert.match(src, /mirror-diag/, "上报地址要对");
|
|
80
|
+
assert.match(src, /Failed to load plugins/, "必须识别首页启动失败文案");
|
|
81
|
+
assert.match(src, /mirror_diag|DSH_MIRROR_DIAG|pagehide/, "必须有无痕静默路径");
|
|
82
|
+
});
|
|
@@ -941,27 +941,45 @@ test("★ 护栏:解析期绝不许往页面塞东西(0.6.14 首屏提示层
|
|
|
941
941
|
assert.ok(!/dsh-ma-boot/.test(css), "首屏提示层的样式必须已彻底删除");
|
|
942
942
|
});
|
|
943
943
|
|
|
944
|
-
test("★
|
|
945
|
-
//
|
|
946
|
-
//
|
|
944
|
+
test("★ 加载疑似失败时的提示:菜单里一行 + **底部可操作提示条**(不是全屏层、解析期不碰 DOM)", () => {
|
|
945
|
+
// 2026-09-25:替代被删除的首屏提示层 —— 换成安全方向。
|
|
946
|
+
// 2026-10-17(真实事故:~/.dsh-remote 被误删 → 镜像页干等 47 秒且无操作入口):
|
|
947
|
+
// · 阈值 25s → 12s(25 秒提示太晚);
|
|
948
|
+
// · 新增底部**可操作**提示条(重试 / 返回设备列表 / 复制诊断信息 / ✕),但它的形态必须仍然是
|
|
949
|
+
// "轻量、可关闭、不遮挡主界面" —— 绝不能退回全屏覆盖层。
|
|
950
|
+
// 具体按钮行为与"关闭后不再弹"见 test/mobile-adapter-load-watch.test.mjs(那里跑真实脚本)。
|
|
947
951
|
const { js: script, css } = injected();
|
|
948
952
|
const gate = script.indexOf("/* —— 主机门解除");
|
|
949
953
|
const preGate = script.slice(script.indexOf('"use strict";'), gate);
|
|
950
|
-
assert.ok(!/appendChild/.test(preGate), "解析期仍不得 appendChild
|
|
954
|
+
assert.ok(!/appendChild/.test(preGate), "解析期仍不得 appendChild(看门狗只置状态/懒创建提示条)");
|
|
951
955
|
|
|
952
956
|
// 提示必须挂在菜单里(菜单是被动展开的 → 天然不会覆盖界面)
|
|
953
957
|
assert.match(script, /dsh-ma-menu[\s\S]{0,600}?data-role="load-warn"/, "提示行必须放在悬浮菜单的 HTML 里");
|
|
954
958
|
assert.match(css, /div\.dsh-ma-menu \.dsh-ma-load-warn/, "样式必须限定在菜单内(不得是 body 级固定层)");
|
|
955
959
|
assert.ok(!/\.dsh-ma-load-warn\s*\{[^}]*position:\s*(fixed|absolute)/.test(css),
|
|
956
|
-
"
|
|
957
|
-
assert.ok(!/\.dsh-ma-load-warn\s*\{[^}]*z-index/.test(css), "
|
|
958
|
-
|
|
959
|
-
//
|
|
960
|
-
assert.match(script, /if \(maLoadReadyNow\(\)\) return;/, "
|
|
961
|
-
assert.match(script,
|
|
960
|
+
"菜单里的提示行不得用 fixed/absolute 定位");
|
|
961
|
+
assert.ok(!/\.dsh-ma-load-warn\s*\{[^}]*z-index/.test(css), "菜单里的提示行不得有 z-index(压层)");
|
|
962
|
+
|
|
963
|
+
// 阈值 12 秒:25 秒太晚(用户实测干等 47 秒才看到任何东西)
|
|
964
|
+
assert.match(script, /if \(maLoadReadyNow\(\)\) return;/, "认出官方界面就不该提示");
|
|
965
|
+
assert.match(script, /var MA_LOAD_WATCH_MS = 12 \* 1000;/, "看门狗阈值常量应为 12 秒");
|
|
966
|
+
assert.match(script, /\}, MA_LOAD_WATCH_MS\);/, "setTimeout 必须用该常量");
|
|
967
|
+
assert.ok(!/25 \* 1000/.test(script), "不得残留 25 秒阈值");
|
|
962
968
|
// 一句话要能直接发给客服
|
|
963
969
|
assert.match(script, /dsh-remote 手机端加载疑似卡住/, "必须给一句可直接发给客服的话");
|
|
964
970
|
assert.match(script, /maLoadCopyLine/, "要能复制(否则用户只能截图)");
|
|
971
|
+
|
|
972
|
+
// 底部提示条:**不是全屏层**(这是 0.6.14 永久盖住 UI 那个事故的直接教训)
|
|
973
|
+
const barRule = /div\.dsh-ma-loadbar\s*\{([\s\S]*?)\}/.exec(css);
|
|
974
|
+
assert.ok(barRule, "应有底部提示条样式");
|
|
975
|
+
assert.ok(!/inset:\s*0/.test(barRule[1]), "提示条不得是全屏层(inset:0)");
|
|
976
|
+
assert.ok(!/top:\s*0(px)?;/.test(barRule[1]), "提示条不得同时钉 top:0(那就是全屏覆盖)");
|
|
977
|
+
assert.match(barRule[1], /max-height:/, "提示条必须限高(不遮挡主界面)");
|
|
978
|
+
assert.match(barRule[1], /bottom:\s*calc\(/, "提示条应贴底部");
|
|
979
|
+
assert.ok(!/z-index:\s*\d{7,}/.test(css), "不得出现百万级 z-index");
|
|
980
|
+
// 关闭后不再弹:脚本里必须有 dismissed 闸门,且 ✕ 走它
|
|
981
|
+
assert.match(script, /dismissed:\s*false/, "提示条必须有「本页不再弹」的状态位");
|
|
982
|
+
assert.match(script, /maLoadDismissBar\(\)/, "✕ 关闭必须走 maLoadDismissBar");
|
|
965
983
|
});
|
|
966
984
|
|
|
967
985
|
// ══════════════════════════════════════════════════════════════════════════════
|
|
@@ -1100,7 +1118,9 @@ test("护栏:延迟角标必须是 FAB 内的子元素(不得新增 body 级
|
|
|
1100
1118
|
// 它不是适配层注入的。原来的写法拿"白名单命中数 === body.children.length"去比,
|
|
1101
1119
|
// 等于要求官方 frame 也得命中白名单 → 恒差 1(假红)。现在改成**指名道姓找漏网节点**,
|
|
1102
1120
|
// 既排除了官方 frame,又比原来更严:多出来的节点会被打印出来。
|
|
1103
|
-
|
|
1121
|
+
// ⚠️ 白名单:dsh-ma-loadbar 是 2026-10-17 新增的「加载慢」底部可操作提示条(懒创建、
|
|
1122
|
+
// 限高、可关闭,不是全屏层;具体形态契约见 test/mobile-adapter-load-watch.test.mjs)。
|
|
1123
|
+
const allowed = ["dsh-ma-fab", "dsh-ma-menu", "dsh-ma-scrim", "dsh-ma-hamburger", "dsh-ma-sessionbar", "dsh-ma-loadbar"];
|
|
1104
1124
|
const stray = env.body.children
|
|
1105
1125
|
.filter((c) => !c._cls.has("pI_x6G_frame")) // 官方 frame 不是我们注入的
|
|
1106
1126
|
.filter((c) => !allowed.some((k) => c._cls.has(k)));
|