@mrrisega/dsh-remote 0.6.11 → 0.6.13
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 +123 -2
- package/clients/dsh-remote/mobile-adapter.mjs +108 -4
- package/clients/dsh-remote/test/bridge-gzip.test.mjs +68 -1
- package/clients/dsh-remote/test/mobile-adapter-late-frame.test.mjs +388 -0
- package/dsh-setup.mjs +55 -15
- package/package.json +4 -4
- package/packages/dsh-remote-web/lib/client.js +137 -0
- package/packages/dsh-remote-web/lib/fix-prompt.js +160 -0
- package/packages/dsh-remote-web/lib/index.js +748 -18
- package/packages/dsh-remote-web/lib/offline-update.mjs +66 -0
- package/packages/dsh-remote-web/lib/tarball-update.js +442 -0
- package/packages/dsh-remote-web/package.json +5 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-bridge.mjs +1572 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dsh-events.mjs +1991 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-client.mjs +728 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim-script.js +767 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/e2ee-shim.mjs +163 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/mobile-adapter.mjs +1679 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/package.json +11 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/src/lifecycle.mjs +45 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-channel.mjs +2825 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wechat-runtime.mjs +1984 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-web/native.html +2526 -0
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +2390 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/LICENSE +20 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/README.md +548 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/browser.js +8 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/index.js +22 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/buffer-util.js +131 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/constants.js +19 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/event-target.js +292 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/extension.js +203 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/limiter.js +55 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/permessage-deflate.js +530 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/receiver.js +743 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/sender.js +607 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/stream.js +161 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/subprotocol.js +62 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/validation.js +152 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/websocket-server.js +562 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/lib/websocket.js +1407 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/package.json +74 -0
- package/packages/dsh-remote-web/runtime/node_modules/ws/wrapper.mjs +21 -0
- package/packages/dsh-remote-web/test/connect-stuck-visibility.test.mjs +396 -0
- package/packages/dsh-remote-web/test/diag-report.test.mjs +236 -0
- package/packages/dsh-remote-web/test/fix-prompt.test.mjs +170 -0
- package/packages/dsh-remote-web/test/runtime-bundle.test.mjs +198 -0
- package/packages/dsh-remote-web/test/tarball-update.test.mjs +460 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
// 故障上报(用户主动)+「让 DeepSeek 帮我修」的用例(0.6.12)。
|
|
2
|
+
//
|
|
3
|
+
// 产品原则(2026-09-23 定的,本文件把它锁死):
|
|
4
|
+
// ① **绝不**后台自动上传日志 —— 只有用户在面板里点「确认上报」才会发;
|
|
5
|
+
// ② 发之前必须能把**将要发送的每一个字**摊给用户看(预览),并且日志可取消附带;
|
|
6
|
+
// ③ 走**既有反馈通道**(企业端 /api/feedback),不需要服务端部署:正文 1 条 +
|
|
7
|
+
// 日志/报告作为**同一条反馈的回复**分片(服务端 content 硬限 2000 字);
|
|
8
|
+
// ④ 手机号/令牌/密码一律先脱敏再出机器;
|
|
9
|
+
// ⑤ 「让 DeepSeek 帮我修」= 给用户提示词 + 让用户把结论报告贴回来(我们发版前就能自救)。
|
|
10
|
+
import assert from "node:assert/strict";
|
|
11
|
+
import http from "node:http";
|
|
12
|
+
import { readFileSync, rmSync } from "node:fs";
|
|
13
|
+
import { mkdir, mkdtemp, readFile, 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 } from "../lib/index.js";
|
|
18
|
+
|
|
19
|
+
const HERE = path.dirname(new URL(import.meta.url).pathname);
|
|
20
|
+
const readOrEmpty = async (p) => { try { return await readFile(p, "utf8"); } catch { return ""; } };
|
|
21
|
+
|
|
22
|
+
/** 假反馈服务:只收 /api/feedback 与 /api/feedback/:id/replies(与企业端契约同构)。 */
|
|
23
|
+
function startFakeFeedback() {
|
|
24
|
+
const seen = [];
|
|
25
|
+
let nextId = 0;
|
|
26
|
+
const srv = http.createServer(async (req, res) => {
|
|
27
|
+
const url = new URL(req.url, "http://x");
|
|
28
|
+
const chunks = [];
|
|
29
|
+
for await (const c of req) chunks.push(c);
|
|
30
|
+
let body = null;
|
|
31
|
+
try { body = JSON.parse(Buffer.concat(chunks).toString("utf8") || "null"); } catch { body = null; }
|
|
32
|
+
seen.push({ method: req.method, path: url.pathname, body, headers: req.headers });
|
|
33
|
+
const send = (code, obj) => { res.writeHead(code, { "content-type": "application/json" }); res.end(JSON.stringify(obj)); };
|
|
34
|
+
if (req.method === "POST" && url.pathname === "/api/feedback") {
|
|
35
|
+
nextId += 1;
|
|
36
|
+
return send(201, { ok: true, feedback: { id: `fb_test_${nextId}`, kind: body?.kind }, thread_token: `tt_${nextId}` });
|
|
37
|
+
}
|
|
38
|
+
if (req.method === "POST" && /^\/api\/feedback\/[^/]+\/replies$/.test(url.pathname)) {
|
|
39
|
+
return send(201, { ok: true });
|
|
40
|
+
}
|
|
41
|
+
return send(404, { error: { code: "not_found" } });
|
|
42
|
+
});
|
|
43
|
+
return new Promise((resolve) => srv.listen(0, "127.0.0.1", () => resolve({
|
|
44
|
+
srv,
|
|
45
|
+
seen,
|
|
46
|
+
port: srv.address().port,
|
|
47
|
+
creates: () => seen.filter((s) => s.method === "POST" && s.path === "/api/feedback"),
|
|
48
|
+
replies: () => seen.filter((s) => /replies$/.test(s.path)),
|
|
49
|
+
})));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function makeEnv({ feedbackPort }) {
|
|
53
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "dsh-diag-"));
|
|
54
|
+
const relayDir = path.join(root, "relay");
|
|
55
|
+
await mkdir(relayDir, { recursive: true });
|
|
56
|
+
// 一个"能加载"的运行环境(入口 + 它点名的文件都在),避免半装判定把用例带偏
|
|
57
|
+
await writeFile(path.join(relayDir, "dsh-setup.mjs"), "// runtime stub\n");
|
|
58
|
+
await writeFile(path.join(relayDir, ".dsh-config.json"), JSON.stringify({
|
|
59
|
+
phone: "13800000000", password: "pw", device_id: "dev-diag",
|
|
60
|
+
api_url: `http://127.0.0.1:${feedbackPort}`, feedback_url: `http://127.0.0.1:${feedbackPort}`,
|
|
61
|
+
}));
|
|
62
|
+
const saved = {};
|
|
63
|
+
for (const k of ["DSH_RELAY_SKIP_SERVICE", "DSH_RELAY_PLATFORM", "DSH_REMOTE_TELEMETRY",
|
|
64
|
+
"DSH_RELAY_DEFAULT_API", "DSH_RELAY_BUNDLED_RUNTIME", "DSH_RELAY_WEDGE_MS"]) saved[k] = process.env[k];
|
|
65
|
+
process.env.DSH_RELAY_SKIP_SERVICE = "1"; // 隔离:用例不碰任何真实服务
|
|
66
|
+
process.env.DSH_RELAY_PLATFORM = "win32"; // 现场是 Windows
|
|
67
|
+
process.env.DSH_REMOTE_TELEMETRY = "0";
|
|
68
|
+
process.env.DSH_RELAY_DEFAULT_API = "http://127.0.0.1:1";
|
|
69
|
+
delete process.env.DSH_RELAY_BUNDLED_RUNTIME;
|
|
70
|
+
delete process.env.DSH_RELAY_WEDGE_MS;
|
|
71
|
+
return {
|
|
72
|
+
relayDir,
|
|
73
|
+
async restore() {
|
|
74
|
+
for (const [k, v] of Object.entries(saved)) {
|
|
75
|
+
if (v === undefined) delete process.env[k]; else process.env[k] = v;
|
|
76
|
+
}
|
|
77
|
+
rmSync(root, { recursive: true, force: true });
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function boot(relayDir) {
|
|
83
|
+
const routes = new Map();
|
|
84
|
+
const disposers = [];
|
|
85
|
+
apply({
|
|
86
|
+
webServer: { register(route) { routes.set(route.path, route.handler); return () => {}; } },
|
|
87
|
+
effect(register) { const d = register(); if (typeof d === "function") disposers.push(d); return d; },
|
|
88
|
+
logger: { info() {}, warn() {} },
|
|
89
|
+
}, { relayDir });
|
|
90
|
+
routes.dispose = () => { for (const d of disposers) { try { d(); } catch { /* 忽略 */ } } };
|
|
91
|
+
return routes;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function serve(routes) {
|
|
95
|
+
const host = http.createServer((req, res) => {
|
|
96
|
+
const handler = routes.get(new URL(req.url, "http://x").pathname);
|
|
97
|
+
(handler || ((_r, rs) => { rs.writeHead(404); rs.end(); }))(req, res);
|
|
98
|
+
});
|
|
99
|
+
await new Promise((resolve) => host.listen(0, "127.0.0.1", resolve));
|
|
100
|
+
return { host, base: `http://127.0.0.1:${host.address().port}` };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const getJson = async (url) => (await fetch(url)).json();
|
|
104
|
+
const postJson = async (url, body) => {
|
|
105
|
+
const r = await fetch(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) });
|
|
106
|
+
return { status: r.status, body: await r.json().catch(() => null) };
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
test("预览:把「将要发送的每一个字」摊开,且不含手机号(脱敏后再出机器)", async () => {
|
|
110
|
+
const fb = await startFakeFeedback();
|
|
111
|
+
const env = await makeEnv({ feedbackPort: fb.port });
|
|
112
|
+
const routes = boot(env.relayDir);
|
|
113
|
+
const { host, base } = await serve(routes);
|
|
114
|
+
try {
|
|
115
|
+
// 日志里塞进手机号 / 令牌 / 崩溃栈:预览必须脱敏,且**保留**真实崩溃原因(那才是能定位的东西)
|
|
116
|
+
await writeFile(path.join(env.relayDir, ".dsh-bridge.log"),
|
|
117
|
+
"[bridge] 用账号 13800000000 登录换取 JWT...\nAuthorization: Bearer eyJhbGciOiJIUzI1NiJ9.abcdefghijklmnop\nError: Cannot find module 'ws'\n");
|
|
118
|
+
await writeFile(path.join(env.relayDir, ".dsh-setup-install.log"),
|
|
119
|
+
"[dsh-remote-web] 自动启动 bridge 未成功(not-installed): 运行环境入口脚本不存在\n");
|
|
120
|
+
|
|
121
|
+
const b = await getJson(`${base}/dsh-remote/diag/bundle`);
|
|
122
|
+
assert.equal(b.ok, true);
|
|
123
|
+
assert.match(b.title, /\[诊断\]/);
|
|
124
|
+
assert.match(b.title, /0\.6\./, "标题要带插件版本,便于后台按版本分桶");
|
|
125
|
+
const all = b.summary + "\n" + b.parts.join("\n");
|
|
126
|
+
for (const need of ["【阶段】", "【bridge】", "【上游】", "【配置目录】", env.relayDir]) {
|
|
127
|
+
assert.ok(all.includes(need), `预览必须包含 ${need}(这些正是 2026-09-23 那次反馈里缺失的证据)`);
|
|
128
|
+
}
|
|
129
|
+
assert.match(all, /守护=/, "守护进程态必须可见(旧版诊断里它是隐身的)");
|
|
130
|
+
assert.equal(b.parts.length, 2, "默认附带两个日志各一份分片");
|
|
131
|
+
assert.ok(!all.includes("13800000000"), "手机号绝不能出现在将发送的内容里");
|
|
132
|
+
assert.match(all, /138\*\*\*\*0000/, "日志里的手机号要被掩码");
|
|
133
|
+
assert.match(all, /Bearer <已隐去>/, "令牌必须隐去");
|
|
134
|
+
assert.match(all, /Cannot find module/, "真实崩溃原因必须保留(否则还是查不出来)");
|
|
135
|
+
assert.match(all, /已登录/, "只说「已登录」,不把手机号外发(服务端据登录态已知是谁)");
|
|
136
|
+
assert.equal(b.noteMax > 0, true, "要告诉前端备注上限");
|
|
137
|
+
} finally { host.close(); routes.dispose(); await env.restore(); fb.srv.close(); }
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("上报:正文 1 条 + 日志分片作为同一条反馈的回复;不点就不发(零自动上报)", async () => {
|
|
141
|
+
const fb = await startFakeFeedback();
|
|
142
|
+
const env = await makeEnv({ feedbackPort: fb.port });
|
|
143
|
+
const routes = boot(env.relayDir);
|
|
144
|
+
const { host, base } = await serve(routes);
|
|
145
|
+
try {
|
|
146
|
+
await writeFile(path.join(env.relayDir, ".dsh-bridge.log"), "line-1\nline-2\n");
|
|
147
|
+
await writeFile(path.join(env.relayDir, ".dsh-setup-install.log"), "install-line-1\n");
|
|
148
|
+
// ① 只打开面板、不点上报 → 反馈服务**一次都不该被打到**
|
|
149
|
+
await getJson(`${base}/dsh-remote/diag/bundle`);
|
|
150
|
+
assert.equal(fb.creates().length + fb.replies().length, 0,
|
|
151
|
+
"预览阶段绝不允许提交任何反馈(隐私红线;插件装载时的装机上报走的是别的端点,不算)");
|
|
152
|
+
|
|
153
|
+
// ② 用户点「确认上报」→ 正文(含他的描述)+ 分片
|
|
154
|
+
const r = await postJson(`${base}/dsh-remote/diag/report`, { note: "手机一直显示没有绑定任何电脑", attachLogs: true });
|
|
155
|
+
assert.equal(r.status, 200);
|
|
156
|
+
assert.equal(r.body.ok, true);
|
|
157
|
+
assert.match(String(r.body.id), /^fb_test_/);
|
|
158
|
+
const creates = fb.creates();
|
|
159
|
+
assert.equal(creates.length, 1, "正文只能是一条反馈");
|
|
160
|
+
assert.equal(creates[0].body.kind, "feedback");
|
|
161
|
+
assert.equal(creates[0].body.category, "bug");
|
|
162
|
+
assert.match(String(creates[0].body.content), /手机一直显示没有绑定任何电脑/, "用户描述必须在正文里");
|
|
163
|
+
assert.match(String(creates[0].body.content), /【阶段】/);
|
|
164
|
+
assert.ok(!String(creates[0].body.content).includes("13800000000"), "正文同样不得带手机号");
|
|
165
|
+
const replies = fb.replies();
|
|
166
|
+
assert.equal(replies.length, 2, "两个日志各一条分片回复(服务端正文 2000 字上限的绕行方案)");
|
|
167
|
+
assert.ok(replies.some((x) => String(x.body.content).includes("line-1")), "bridge 日志必须在分片里");
|
|
168
|
+
assert.equal(creates[0].headers["x-dsh-device"], "dev-diag", "要带上设备身份(服务端据此关联机器)");
|
|
169
|
+
|
|
170
|
+
// ③ 用户取消「附带日志」→ 只有正文
|
|
171
|
+
const r2 = await postJson(`${base}/dsh-remote/diag/report`, { note: "只发正文", attachLogs: false });
|
|
172
|
+
assert.equal(r2.body.parts, 0);
|
|
173
|
+
assert.equal(fb.creates().length, 2);
|
|
174
|
+
assert.equal(fb.replies().length, 2, "取消附带的这一条不产生新的分片");
|
|
175
|
+
|
|
176
|
+
// ④ 回传「结论报告」:作为分片进同一条反馈
|
|
177
|
+
const r3 = await postJson(`${base}/dsh-remote/diag/report`, { note: "来自让 DeepSeek 帮我修", attachLogs: false, report: "=== dsh-remote 诊断结论 ===\n机制判定: ②守护活着没起作用" });
|
|
178
|
+
assert.equal(r3.body.parts, 1);
|
|
179
|
+
assert.match(String(fb.replies().at(-1).body.content), /机制判定/, "用户回传的报告必须到达");
|
|
180
|
+
} finally { host.close(); routes.dispose(); await env.restore(); fb.srv.close(); }
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("上报:坏 JSON 与超大 body 都要给人话,且绝不打挂宿主", async () => {
|
|
184
|
+
const fb = await startFakeFeedback();
|
|
185
|
+
const env = await makeEnv({ feedbackPort: fb.port });
|
|
186
|
+
const routes = boot(env.relayDir);
|
|
187
|
+
const { host, base } = await serve(routes);
|
|
188
|
+
try {
|
|
189
|
+
const bad = await fetch(`${base}/dsh-remote/diag/report`, {
|
|
190
|
+
method: "POST", headers: { "content-type": "application/json" }, body: "{不是 JSON",
|
|
191
|
+
});
|
|
192
|
+
assert.equal(bad.status, 400);
|
|
193
|
+
assert.match(String((await bad.json()).error), /JSON/);
|
|
194
|
+
|
|
195
|
+
const huge = await fetch(`${base}/dsh-remote/diag/report`, {
|
|
196
|
+
method: "POST", headers: { "content-type": "application/json" },
|
|
197
|
+
body: JSON.stringify({ note: "x", report: "y".repeat(300 * 1024) }),
|
|
198
|
+
});
|
|
199
|
+
assert.equal(huge.status, 413, "超大 body 必须被挡下(保护宿主内存)");
|
|
200
|
+
assert.equal(fb.creates().length, 0, "被挡下的请求不得提交到反馈服务");
|
|
201
|
+
} finally { host.close(); routes.dispose(); await env.restore(); fb.srv.close(); }
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("修复提示词:带当前故障上下文(目录/上游/版本),便于用户本机 DSH 直接照做", async () => {
|
|
205
|
+
const fb = await startFakeFeedback();
|
|
206
|
+
const env = await makeEnv({ feedbackPort: fb.port });
|
|
207
|
+
const routes = boot(env.relayDir);
|
|
208
|
+
const { host, base } = await serve(routes);
|
|
209
|
+
try {
|
|
210
|
+
const b = await getJson(`${base}/dsh-remote/diag/fix-prompt`);
|
|
211
|
+
assert.equal(b.ok, true);
|
|
212
|
+
assert.match(b.prompt, new RegExp(env.relayDir.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")), "提示词要带上本机配置目录");
|
|
213
|
+
assert.match(b.prompt, /127\.0\.0\.1:3080/, "提示词要带上上游地址(端口错配是本次事故的最大嫌疑)");
|
|
214
|
+
assert.match(b.prompt, /\.dsh-bridge\.log/);
|
|
215
|
+
assert.match(b.prompt, /\.dsh-setup-install\.log/);
|
|
216
|
+
assert.match(b.reportTitle, /dsh-remote 诊断结论/, "报告格式要有固定首行,便于后台机器解析");
|
|
217
|
+
assert.ok(Array.isArray(b.reportKeys) && b.reportKeys.includes("机制判定") && b.reportKeys.includes("修复结果"));
|
|
218
|
+
} finally { host.close(); routes.dispose(); await env.restore(); fb.srv.close(); }
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("前端契约:两个入口都在连接卡上;上报只在用户点击后发生(源码级)", () => {
|
|
222
|
+
const client = readFileSync(path.join(HERE, "..", "lib", "client.js"), "utf8");
|
|
223
|
+
assert.match(client, /上报故障给开发者/, "面板必须有「上报故障给开发者」入口");
|
|
224
|
+
assert.match(client, /让 DeepSeek 帮我修/, "面板必须有「让 DeepSeek 帮我修」入口");
|
|
225
|
+
assert.match(client, /将要发送/, "上报前必须明确告诉用户「将要发送」的是什么");
|
|
226
|
+
assert.match(client, /复制提示词/, "修复提示词必须可一键复制");
|
|
227
|
+
assert.match(client, /回传结论报告/, "用户要能把 DSH 的结论报告回传给我们");
|
|
228
|
+
// 只在用户点击后调用:这两个接口各只有**一个**调用点,且都在对应的事件处理函数里
|
|
229
|
+
assert.equal((client.match(/dsh-remote\/diag\/report/g) || []).length, 1,
|
|
230
|
+
"上报接口只能有一个调用点(防止有人在渲染/挂载时就发)");
|
|
231
|
+
assert.equal((client.match(/dsh-remote\/diag\/bundle/g) || []).length, 1);
|
|
232
|
+
assert.equal((client.match(/dsh-remote\/diag\/fix-prompt/g) || []).length, 1);
|
|
233
|
+
const openFn = client.slice(client.indexOf("var openDiagReport = function"), client.indexOf("var openFixPrompt = function"));
|
|
234
|
+
assert.match(openFn, /diag\/bundle/, "预览只在用户打开上报时才拉取");
|
|
235
|
+
assert.ok(!/useEffect/.test(openFn), "预览不得挂在 useEffect 里自动触发");
|
|
236
|
+
});
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 「连接卡住 → 一键修复提示词 + 结论报告」契约测试(node:test,零框架)。
|
|
3
|
+
*
|
|
4
|
+
* 背景(真实事故,2026-09-23,Windows 用户):
|
|
5
|
+
* 插件面板卡在「连接阶段: starting(正在启动 Bridge…)」,手机端看不到任何设备,反复修都没用。
|
|
6
|
+
* 事后定位到三种机制(半装运行环境 / 守护活着没起作用 / bridge 起来就崩),它们在面板上长得一模一样,
|
|
7
|
+
* 面板侧诊断分不开;只能由用户本机的 agent 在自己电脑上取证。
|
|
8
|
+
* 所以面板给用户一段可一键复制的中文提示词,并要求回传一份**固定格式**的报告。
|
|
9
|
+
*
|
|
10
|
+
* 这份用例锁死提示词的**可执行性与边界**,而不是文风:
|
|
11
|
+
* ① 六步齐备且顺序正确(第一步读日志、最后一步出报告),每步都有「验证」;
|
|
12
|
+
* ② 三条判据各自依赖的关键路径/命令都要出现在文案里(日志、lifecycle.mjs、ws、端口、守护、状态文件);
|
|
13
|
+
* ③ 隐私红线:脱敏示例、禁止贴手机号/密码/token、禁止删除 ~/.dsh-remote;
|
|
14
|
+
* ④ 报告格式固定:模板首行就是标题,六个字段齐全,「修复结果」只允许已修复/未修复;
|
|
15
|
+
* ⑤ buildFixPrompt() 无参不崩、任何情况下不出现 undefined,传入 ctx 的值真的进了文案。
|
|
16
|
+
*/
|
|
17
|
+
import assert from "node:assert/strict";
|
|
18
|
+
import test from "node:test";
|
|
19
|
+
import {
|
|
20
|
+
BRIDGE_LOG_FILE,
|
|
21
|
+
BRIDGE_STATE_FILE,
|
|
22
|
+
DEFAULT_RELAY_DIR,
|
|
23
|
+
DEFAULT_UPSTREAM_PORT,
|
|
24
|
+
FIX_PROMPT,
|
|
25
|
+
INSTALL_LOG_FILE,
|
|
26
|
+
REPORT_FORMAT,
|
|
27
|
+
REPORT_KEYS,
|
|
28
|
+
REPORT_TITLE,
|
|
29
|
+
UPSTREAM_FILE,
|
|
30
|
+
buildFixPrompt,
|
|
31
|
+
} from "../lib/fix-prompt.js";
|
|
32
|
+
|
|
33
|
+
/** 默认 ctx 渲染出来的提示词(无参调用的结果)。 */
|
|
34
|
+
const DEFAULT_PROMPT = buildFixPrompt();
|
|
35
|
+
|
|
36
|
+
test("提示词覆盖两份日志:既有默认值,也支持面板传入的绝对路径", () => {
|
|
37
|
+
// 默认值(占位符之外必须写明默认位置,否则用户找不到日志)
|
|
38
|
+
assert.ok(FIX_PROMPT.includes(`~/.dsh-remote/${BRIDGE_LOG_FILE}`), "模板里应写明 bridge 日志的默认位置");
|
|
39
|
+
assert.ok(FIX_PROMPT.includes(`~/.dsh-remote/${INSTALL_LOG_FILE}`), "模板里应写明安装日志的默认位置");
|
|
40
|
+
assert.ok(DEFAULT_PROMPT.includes(`~/.dsh-remote/${BRIDGE_LOG_FILE}`), "无参渲染后应含 bridge 日志默认路径");
|
|
41
|
+
assert.ok(DEFAULT_PROMPT.includes(`~/.dsh-remote/${INSTALL_LOG_FILE}`), "无参渲染后应含安装日志默认路径");
|
|
42
|
+
// 面板上报的真实路径(Windows 形态)必须原样出现,且不能再留占位符
|
|
43
|
+
const prompt = buildFixPrompt({
|
|
44
|
+
bridgeLogPath: "C:\\Users\\u\\.dsh-remote\\.dsh-bridge.log",
|
|
45
|
+
installLogPath: "C:\\Users\\u\\.dsh-remote\\.dsh-setup-install.log",
|
|
46
|
+
});
|
|
47
|
+
assert.ok(prompt.includes("C:\\Users\\u\\.dsh-remote\\.dsh-bridge.log"), "传入的 bridge 日志路径应出现在提示词里");
|
|
48
|
+
assert.ok(prompt.includes("C:\\Users\\u\\.dsh-remote\\.dsh-setup-install.log"), "传入的安装日志路径应出现在提示词里");
|
|
49
|
+
assert.ok(!prompt.includes("{{"), `渲染后不应残留占位符: ${prompt.match(/\{\{[^}]*\}\}/g)}`);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("六步齐备且顺序正确,每一步都要求 agent 自己验证", () => {
|
|
53
|
+
// 用「第 N 步 ·」定位标题(正文里会出现「需按第 3 步核实」这类交叉引用,不能只匹配「第 N 步」)
|
|
54
|
+
const marks = ["第 1 步 ·", "第 2 步 ·", "第 3 步 ·", "第 4 步 ·", "第 5 步 ·", "第 6 步 ·"];
|
|
55
|
+
let last = -1;
|
|
56
|
+
for (const m of marks) {
|
|
57
|
+
const at = DEFAULT_PROMPT.indexOf(m);
|
|
58
|
+
assert.ok(at > last, `${m} 应存在且顺序在 ${marks[marks.indexOf(m) - 1] || "开头"} 之后`);
|
|
59
|
+
last = at;
|
|
60
|
+
}
|
|
61
|
+
// 每步都要有「验证:」小节(6 步各自验证,而不是最后统一看一眼)
|
|
62
|
+
const checks = DEFAULT_PROMPT.match(/验证[::]/g) || [];
|
|
63
|
+
assert.ok(checks.length >= 6, `每一步都应自带验证要求(实际 ${checks.length} 处)`);
|
|
64
|
+
// 第 1 步必须是读日志,第 6 步必须是出报告
|
|
65
|
+
assert.match(DEFAULT_PROMPT.slice(DEFAULT_PROMPT.indexOf("第 1 步 ·"), DEFAULT_PROMPT.indexOf("第 2 步 ·")), /日志/);
|
|
66
|
+
assert.match(DEFAULT_PROMPT.slice(DEFAULT_PROMPT.indexOf("第 6 步 ·")), /报告/);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("第 2 步:运行环境完整性清单(lifecycle.mjs / dsh-bridge.mjs / ws)与重新补装命令", () => {
|
|
70
|
+
assert.ok(DEFAULT_PROMPT.includes("clients/dsh-remote/src/lifecycle.mjs"), "必须点名 lifecycle.mjs(① 号机制缺的就是它)");
|
|
71
|
+
assert.ok(DEFAULT_PROMPT.includes("clients/dsh-remote/dsh-bridge.mjs"), "必须点名 dsh-bridge.mjs");
|
|
72
|
+
assert.ok(DEFAULT_PROMPT.includes("node_modules/ws"), "必须点名 node_modules/ws(缺 ws = bridge 起来就崩)");
|
|
73
|
+
assert.ok(/dsh-setup\.mjs/.test(DEFAULT_PROMPT), "必须点名 dsh-setup.mjs 是否在(半装的判据)");
|
|
74
|
+
assert.ok(DEFAULT_PROMPT.includes("Cannot find module"), "应给出半装的典型报错原文,便于对号入座");
|
|
75
|
+
assert.ok(DEFAULT_PROMPT.includes("npx @mrrisega/dsh-remote"), "缺失时应指导用一键安装器补装");
|
|
76
|
+
assert.ok(/node --check/.test(DEFAULT_PROMPT), "补装后要求自己做语法/加载验证");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("第 3 步:核对 dsh web 实际监听端口(netstat / --port / .dsh-upstream / 3080)", () => {
|
|
80
|
+
assert.ok(DEFAULT_PROMPT.includes("netstat -ano | findstr LISTENING"), "Windows 要给出 netstat 查监听端口");
|
|
81
|
+
assert.ok(DEFAULT_PROMPT.includes("--port"), "要提示看 dsh web 启动命令里的 --port");
|
|
82
|
+
assert.ok(DEFAULT_PROMPT.includes(`~/.dsh-remote/${UPSTREAM_FILE}`) || DEFAULT_PROMPT.includes(UPSTREAM_FILE), "要读 .dsh-upstream 端口文件");
|
|
83
|
+
assert.ok(DEFAULT_PROMPT.includes(String(DEFAULT_UPSTREAM_PORT)), "要写明默认端口 3080 只是回退值");
|
|
84
|
+
assert.ok(DEFAULT_PROMPT.includes("DSH_BRIDGE_UPSTREAM"), "端口不一致时应给出显式覆盖上游地址的手段");
|
|
85
|
+
// 端口不一致 → 按实际端口修正并重启 bridge
|
|
86
|
+
assert.match(DEFAULT_PROMPT, /不一致[^]{0,200}重启 bridge/);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("第 4 步:能识别「守护活着但没起作用」,并结束卡死守护后重新拉起", () => {
|
|
90
|
+
assert.ok(DEFAULT_PROMPT.includes("dsh-setup.mjs run"), "要给出守护进程的命令行形态");
|
|
91
|
+
assert.ok(DEFAULT_PROMPT.includes("活着但没起作用"), "要显式命名②号机制的现场特征");
|
|
92
|
+
assert.ok(DEFAULT_PROMPT.includes("taskkill /PID <pid> /T /F"), "Windows 要用 taskkill 连子进程一起收");
|
|
93
|
+
assert.ok(/\bkill <pid>/.test(DEFAULT_PROMPT), "macOS/Linux 要给出 kill(并有 kill -9 兜底)");
|
|
94
|
+
assert.ok(DEFAULT_PROMPT.includes("kill -9"), "不退出时的兜底信号应写明");
|
|
95
|
+
assert.match(DEFAULT_PROMPT, /taskkill[\s\S]{0,400}\bkill <pid>/, "两种平台的结束方式应写在一起(用户不一定在哪个系统上)");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("第 5 步:以 .dsh-bridge-state.json 的 device_id + phase=online 收口,并要求如实回报", () => {
|
|
99
|
+
assert.ok(DEFAULT_PROMPT.includes(`~/.dsh-remote/${BRIDGE_STATE_FILE}`), "状态文件的默认路径必须出现");
|
|
100
|
+
assert.ok(DEFAULT_PROMPT.includes("device_id"), "要看 device_id(设备身份是否落盘)");
|
|
101
|
+
assert.ok(DEFAULT_PROMPT.includes("online"), "phase=online 是唯一成功判据");
|
|
102
|
+
assert.ok(DEFAULT_PROMPT.includes("last_error"), "没通时要摘 last_error 原文");
|
|
103
|
+
assert.match(DEFAULT_PROMPT, /没修好也要照实写/, "必须允许并鼓励如实报告失败");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("隐私红线:脱敏示例、禁止凭据/贴文件内容、禁止删 ~/.dsh-remote", () => {
|
|
107
|
+
assert.ok(DEFAULT_PROMPT.includes("138****0000"), "必须给出手机号掩码示例");
|
|
108
|
+
assert.match(DEFAULT_PROMPT, /不得[\s\S]{0,80}(手机号|密码|token)/, "必须明文禁止把凭据写进报告");
|
|
109
|
+
assert.ok(/密码/.test(DEFAULT_PROMPT) && /token/i.test(DEFAULT_PROMPT) && /bridge_secret/.test(DEFAULT_PROMPT), "手机号/密码/token/bridge_secret 都要点名");
|
|
110
|
+
assert.ok(DEFAULT_PROMPT.includes("JWT") && /cookie/i.test(DEFAULT_PROMPT), "JWT 与 cookie 也要点名");
|
|
111
|
+
assert.match(DEFAULT_PROMPT, /不要删除/, "必须明确不要删除运行环境目录");
|
|
112
|
+
assert.match(DEFAULT_PROMPT, /不要删除[^]{0,60}账号与设备身份/, "要给出「删了就丢账号/设备身份」的理由");
|
|
113
|
+
assert.match(DEFAULT_PROMPT, /不要把用户的文件内容、会话内容/, "禁止把用户文件/会话内容贴进报告");
|
|
114
|
+
assert.ok(/已隐去/.test(DEFAULT_PROMPT), "token/密钥应统一写成 <已隐去>");
|
|
115
|
+
// 反向:提示词不得示范真实凭据形态
|
|
116
|
+
assert.ok(!/Bearer\s+[A-Za-z0-9._-]{8,}/.test(DEFAULT_PROMPT), "提示词本身不得出现形似真实 token 的串");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("报告格式:REPORT_KEYS 六个字段齐全,REPORT_FORMAT 首行即固定标题", () => {
|
|
120
|
+
for (const key of ["症状", "机制判定", "关键证据", "已做的修复", "修复结果", "仍需人工处理"]) {
|
|
121
|
+
assert.ok(REPORT_KEYS.includes(key), `REPORT_KEYS 应覆盖「${key}」`);
|
|
122
|
+
}
|
|
123
|
+
assert.equal(REPORT_KEYS.length, 6, "REPORT_KEYS 就是这六项(多了会让解析口径漂移)");
|
|
124
|
+
assert.equal(REPORT_FORMAT.split("\n")[0], REPORT_TITLE, "报告模板首行必须是固定标题");
|
|
125
|
+
assert.equal(REPORT_TITLE, "=== dsh-remote 诊断结论 ===");
|
|
126
|
+
// 六项在模板里都要有对应行,且顺序与 REPORT_KEYS 一致
|
|
127
|
+
const positions = REPORT_KEYS.map((k) => REPORT_FORMAT.indexOf(k + ":"));
|
|
128
|
+
assert.ok(positions.every((p) => p >= 0), "六项都要在模板里有 <字段>: 行");
|
|
129
|
+
assert.deepEqual([...positions].sort((a, b) => a - b), positions, "模板字段顺序应与 REPORT_KEYS 一致");
|
|
130
|
+
// 机制判定四选一 + 修复结果二选一(机器解析靠这两个枚举)
|
|
131
|
+
assert.match(REPORT_FORMAT, /①半装运行环境[\s\S]*②守护活着没起作用[\s\S]*③bridge 起来就崩[\s\S]*其它/);
|
|
132
|
+
assert.match(REPORT_FORMAT, /修复结果: 已修复 \| 未修复/);
|
|
133
|
+
// 报告模板本身也要进提示词,并要求放进围栏里、标题在围栏内第一行
|
|
134
|
+
assert.ok(DEFAULT_PROMPT.includes(REPORT_FORMAT), "提示词里应内嵌完整报告模板");
|
|
135
|
+
assert.match(DEFAULT_PROMPT, /```[\s\S]*=== dsh-remote 诊断结论 ===/, "模板应被要求放进 ``` 围栏且标题是围栏内第一行");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("buildFixPrompt() 无参不崩、任何字段缺省都不出现 undefined;传入 ctx 的值真的进文案", () => {
|
|
139
|
+
assert.equal(typeof DEFAULT_PROMPT, "string");
|
|
140
|
+
assert.ok(DEFAULT_PROMPT.length > 1000, "应是一段完整可执行的提示词");
|
|
141
|
+
assert.ok(!DEFAULT_PROMPT.includes("undefined"), "缺省时不得出现 undefined");
|
|
142
|
+
assert.ok(!DEFAULT_PROMPT.includes("null"), "缺省时不得出现 null");
|
|
143
|
+
assert.ok(!DEFAULT_PROMPT.includes("{{"), "缺省时不得残留占位符");
|
|
144
|
+
// 默认值本身要可读(而不是空白或 "unknown")
|
|
145
|
+
assert.ok(DEFAULT_PROMPT.includes(DEFAULT_RELAY_DIR), "应回退到默认运行环境目录");
|
|
146
|
+
assert.ok(DEFAULT_PROMPT.includes("starting(正在启动 Bridge…)"), "应回退到面板的 starting 阶段文案");
|
|
147
|
+
assert.ok(DEFAULT_PROMPT.includes(`http://127.0.0.1:${DEFAULT_UPSTREAM_PORT}`), "应回退到默认上游地址");
|
|
148
|
+
assert.ok(/未知/.test(DEFAULT_PROMPT), "版本缺失时应写「未知」而不是留空");
|
|
149
|
+
|
|
150
|
+
// 显式 ctx:每个字段都要在文本里出现,且日志路径能由 relayDir 推导
|
|
151
|
+
const prompt = buildFixPrompt({
|
|
152
|
+
relayDir: "/home/u/.dsh-remote",
|
|
153
|
+
pluginVersion: "0.6.12",
|
|
154
|
+
runtimeVersion: "0.6.12",
|
|
155
|
+
upstreamUrl: "http://127.0.0.1:8090",
|
|
156
|
+
phaseText: "connecting",
|
|
157
|
+
});
|
|
158
|
+
for (const value of ["/home/u/.dsh-remote", "0.6.12", "http://127.0.0.1:8090", "connecting"]) {
|
|
159
|
+
assert.ok(prompt.includes(value), `ctx 值应出现在提示词里: ${value}`);
|
|
160
|
+
}
|
|
161
|
+
assert.ok(prompt.includes(`/home/u/.dsh-remote/${BRIDGE_LOG_FILE}`), "relayDir 应能推导出 bridge 日志路径");
|
|
162
|
+
assert.ok(prompt.includes(`/home/u/.dsh-remote/${INSTALL_LOG_FILE}`), "relayDir 应能推导出安装日志路径");
|
|
163
|
+
assert.ok(prompt.includes(`/home/u/.dsh-remote/${BRIDGE_STATE_FILE}`), "relayDir 应能推导出状态文件路径");
|
|
164
|
+
assert.ok(!prompt.includes("undefined"));
|
|
165
|
+
// 尾部斜杠 / 全空白 / 非字符串 都要优雅降级,不能拼出 "//" 或 undefined
|
|
166
|
+
const sloppy = buildFixPrompt({ relayDir: "/home/u/.dsh-remote/", pluginVersion: " ", runtimeVersion: null, upstreamUrl: undefined, phaseText: 42 });
|
|
167
|
+
assert.ok(!sloppy.includes("undefined") && !sloppy.includes("//" + BRIDGE_LOG_FILE), "非法/空白 ctx 必须降级,不得拼出坏路径");
|
|
168
|
+
assert.ok(sloppy.includes(`/home/u/.dsh-remote/${BRIDGE_LOG_FILE}`), "带尾斜杠的 relayDir 应被规整");
|
|
169
|
+
assert.equal(buildFixPrompt(null), DEFAULT_PROMPT, "ctx 为 null 时与无参调用等价");
|
|
170
|
+
});
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// 运行环境随插件分发(0.6.12)的用例。
|
|
2
|
+
//
|
|
3
|
+
// 背景(2026-09-23 生产取证):Windows 上「装插件 → 后台 npx 装运行环境」是最大的流失源 ——
|
|
4
|
+
// 近 14 天 427 台 win32 里 138 台 install_failed(32%);v0.6.6 的 95 台里 73 台装机失败,
|
|
5
|
+
// 其中 48 台点过「一键更新」却一台都没救回来(Node ≥20.12 上 spawn npx.cmd 不带 shell 抛 EINVAL)。
|
|
6
|
+
// 现在插件包自带 runtime/(scripts/bundle-runtime.mjs 打进 npm 包与 GitHub Release 资产),
|
|
7
|
+
// 补装退化为**本地拷贝**:零网络、零 npx、零控制台代码页,且运行环境版本恒等于插件版本。
|
|
8
|
+
//
|
|
9
|
+
// 本文件锁死三条不变量:
|
|
10
|
+
// ① 打包产物完整(入口 + 入口点名的 clients 文件 + ws),且**不含** test/ 这类垃圾;
|
|
11
|
+
// ② 半装运行环境(入口在、依赖不在)→ 用自带运行环境**整份补齐**,全程不碰 npx;
|
|
12
|
+
// ③ 自带运行环境本身不完整/不存在 → 老实回落到 npx(绝不假装成功)。
|
|
13
|
+
import assert from "node:assert/strict";
|
|
14
|
+
import http from "node:http";
|
|
15
|
+
import { spawnSync } from "node:child_process";
|
|
16
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
|
|
18
|
+
import os from "node:os";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import test from "node:test";
|
|
21
|
+
import { apply } from "../lib/index.js";
|
|
22
|
+
|
|
23
|
+
const HERE = path.dirname(new URL(import.meta.url).pathname);
|
|
24
|
+
const ROOT = path.join(HERE, "..", "..", "..");
|
|
25
|
+
const BUNDLER = path.join(ROOT, "scripts", "bundle-runtime.mjs");
|
|
26
|
+
const PKG_JSON = path.join(ROOT, "packages", "dsh-remote-web", "package.json");
|
|
27
|
+
|
|
28
|
+
const readOrEmpty = async (p) => { try { return await readFile(p, "utf8"); } catch { return ""; } };
|
|
29
|
+
async function waitFor(fn, { timeout = 5000, step = 25 } = {}) {
|
|
30
|
+
const deadline = Date.now() + timeout;
|
|
31
|
+
for (;;) {
|
|
32
|
+
if (await fn()) return true;
|
|
33
|
+
if (Date.now() > deadline) return false;
|
|
34
|
+
await new Promise((r) => setTimeout(r, step));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 假 npx:只记参数(用例必须证明「该走本地拷贝时一次 npx 都不调」)。 */
|
|
39
|
+
const FAKE_NPX = `#!/bin/sh
|
|
40
|
+
printf '%s\\n' "$*" >> "$DSH_TEST_NPX_LOG"
|
|
41
|
+
exit 0
|
|
42
|
+
`;
|
|
43
|
+
|
|
44
|
+
async function makeEnv() {
|
|
45
|
+
const root = await mkdtemp(path.join(os.tmpdir(), "dsh-rtbundle-"));
|
|
46
|
+
const fakeBin = path.join(root, "bin");
|
|
47
|
+
const relayDir = path.join(root, "relay");
|
|
48
|
+
const fakeHome = path.join(root, "home"); // 必须伪造 HOME(见 setup-output.test.mjs 的静态护栏)
|
|
49
|
+
await mkdir(fakeBin, { recursive: true });
|
|
50
|
+
await mkdir(relayDir, { recursive: true });
|
|
51
|
+
await mkdir(fakeHome, { recursive: true });
|
|
52
|
+
const npxLog = path.join(root, "npx.log");
|
|
53
|
+
await writeFile(npxLog, "");
|
|
54
|
+
for (const name of ["npx", "npx.cmd"]) {
|
|
55
|
+
await writeFile(path.join(fakeBin, name), FAKE_NPX);
|
|
56
|
+
await chmodSync(path.join(fakeBin, name), 0o755);
|
|
57
|
+
}
|
|
58
|
+
const saved = {};
|
|
59
|
+
for (const k of ["HOME", "PATH", "DSH_TEST_NPX_LOG", "DSH_SETUP_NPX_DIR", "DSH_RELAY_SKIP_SERVICE",
|
|
60
|
+
"DSH_RELAY_PLATFORM", "DSH_REMOTE_TELEMETRY", "DSH_RELAY_DEFAULT_API", "DSH_RELAY_BUNDLED_RUNTIME"]) {
|
|
61
|
+
saved[k] = process.env[k];
|
|
62
|
+
}
|
|
63
|
+
process.env.HOME = fakeHome;
|
|
64
|
+
process.env.PATH = `${fakeBin}:${saved.PATH || ""}`;
|
|
65
|
+
process.env.DSH_TEST_NPX_LOG = npxLog;
|
|
66
|
+
process.env.DSH_SETUP_NPX_DIR = fakeBin;
|
|
67
|
+
process.env.DSH_RELAY_PLATFORM = "win32"; // 反馈来自 Windows;win32 分支不碰 launchctl
|
|
68
|
+
process.env.DSH_REMOTE_TELEMETRY = "0";
|
|
69
|
+
process.env.DSH_RELAY_DEFAULT_API = "http://127.0.0.1:1";
|
|
70
|
+
delete process.env.DSH_RELAY_SKIP_SERVICE; // 走真实分支(npx 是假的)
|
|
71
|
+
// 默认指向不存在的目录:本机跑过 npm pack / npm publish 后 packages/dsh-remote-web/runtime 会存在,
|
|
72
|
+
// 那会让"没有自带运行环境"的用例前提失效(实测:全量套件里这条因此变红)。
|
|
73
|
+
process.env.DSH_RELAY_BUNDLED_RUNTIME = "/nonexistent-dsh-bundled";
|
|
74
|
+
return {
|
|
75
|
+
root, relayDir, npxLog,
|
|
76
|
+
async restore() {
|
|
77
|
+
for (const [k, v] of Object.entries(saved)) {
|
|
78
|
+
if (v === undefined) delete process.env[k]; else process.env[k] = v;
|
|
79
|
+
}
|
|
80
|
+
rmSync(root, { recursive: true, force: true });
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function boot(relayDir) {
|
|
86
|
+
const routes = new Map();
|
|
87
|
+
const disposers = [];
|
|
88
|
+
apply({
|
|
89
|
+
webServer: { register(route) { routes.set(route.path, route.handler); return () => {}; } },
|
|
90
|
+
effect(register) { const d = register(); if (typeof d === "function") disposers.push(d); return d; },
|
|
91
|
+
logger: { info() {}, warn() {} },
|
|
92
|
+
}, { relayDir });
|
|
93
|
+
routes.dispose = () => { for (const d of disposers) { try { d(); } catch { /* 忽略 */ } } };
|
|
94
|
+
return routes;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function serve(routes) {
|
|
98
|
+
const host = http.createServer((req, res) => {
|
|
99
|
+
const handler = routes.get(new URL(req.url, "http://x").pathname);
|
|
100
|
+
(handler || ((_r, rs) => { rs.writeHead(404); rs.end(); }))(req, res);
|
|
101
|
+
});
|
|
102
|
+
await new Promise((resolve) => host.listen(0, "127.0.0.1", resolve));
|
|
103
|
+
return { host, base: `http://127.0.0.1:${host.address().port}` };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** 半装运行环境:入口在,且它**点名**的文件不在(这正是用户卡死的现场)。 */
|
|
107
|
+
const HALF_INSTALL_ENTRY = `import { childStopped } from "./clients/dsh-remote/src/lifecycle.mjs";
|
|
108
|
+
export const bridge = new URL("./clients/dsh-remote/dsh-bridge.mjs", import.meta.url);
|
|
109
|
+
export { childStopped };
|
|
110
|
+
`;
|
|
111
|
+
|
|
112
|
+
test("打包脚本:产物完整(入口 + 点名文件 + ws)、不含 test/,且自检通过", async () => {
|
|
113
|
+
const out = await mkdtemp(path.join(os.tmpdir(), "dsh-runtime-out-"));
|
|
114
|
+
try {
|
|
115
|
+
const r = spawnSync(process.execPath, [BUNDLER, "--out", out], { encoding: "utf8" });
|
|
116
|
+
assert.equal(r.status, 0, `打包脚本必须成功:${r.stdout}${r.stderr}`);
|
|
117
|
+
for (const rel of [
|
|
118
|
+
"dsh-setup.mjs",
|
|
119
|
+
"clients/dsh-remote/dsh-bridge.mjs",
|
|
120
|
+
"clients/dsh-remote/src/lifecycle.mjs",
|
|
121
|
+
"node_modules/ws/package.json",
|
|
122
|
+
]) {
|
|
123
|
+
assert.ok(existsSync(path.join(out, rel)), `产物缺 ${rel} —— 半装运行环境就是用户卡死的直接原因`);
|
|
124
|
+
}
|
|
125
|
+
assert.ok(!existsSync(path.join(out, "clients", "dsh-remote", "test")), "测试目录不该进运行时产物(体积)");
|
|
126
|
+
// 自检模式:发布门禁会用它
|
|
127
|
+
const chk = spawnSync(process.execPath, [BUNDLER, "--check", "--out", out], { encoding: "utf8" });
|
|
128
|
+
assert.equal(chk.status, 0, `自检必须通过:${chk.stdout}${chk.stderr}`);
|
|
129
|
+
|
|
130
|
+
// 反例:产物不完整时自检必须**失败**(宁可发布失败,也不发一个半装运行环境出去)
|
|
131
|
+
rmSync(path.join(out, "clients", "dsh-remote", "src", "lifecycle.mjs"));
|
|
132
|
+
const bad = spawnSync(process.execPath, [BUNDLER, "--check", "--out", out], { encoding: "utf8" });
|
|
133
|
+
assert.notEqual(bad.status, 0, "缺文件时自检必须非 0");
|
|
134
|
+
assert.match(String(bad.stderr || bad.stdout), /不完整/);
|
|
135
|
+
} finally { rmSync(out, { recursive: true, force: true }); }
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("打包契约:插件 package.json 声明了 runtime 与 prepack,且 runtime/ 不入库", () => {
|
|
139
|
+
const pkg = JSON.parse(readFileSync(PKG_JSON, "utf8"));
|
|
140
|
+
assert.ok(Array.isArray(pkg.files) && pkg.files.includes("runtime"),
|
|
141
|
+
"files 必须含 runtime,否则 npm pack / GitHub Release 资产里没有运行环境");
|
|
142
|
+
assert.match(String(pkg.scripts && pkg.scripts.prepack || ""), /bundle-runtime\.mjs/,
|
|
143
|
+
"prepack 必须在打包前生成 runtime/(发布流程依赖它)");
|
|
144
|
+
const gi = readFileSync(path.join(ROOT, ".gitignore"), "utf8");
|
|
145
|
+
assert.match(gi, /packages\/dsh-remote-web\/runtime\//,
|
|
146
|
+
"runtime/ 是构建产物:必须 gitignore,否则 dsh-setup.mjs/clients 会有两份互相漂移的副本");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("半装运行环境 → 用插件自带的运行环境整份补齐,全程不碰 npx(Windows 装机失败类的根治)", async () => {
|
|
150
|
+
const out = await mkdtemp(path.join(os.tmpdir(), "dsh-runtime-src-"));
|
|
151
|
+
spawnSync(process.execPath, [BUNDLER, "--out", out], { encoding: "utf8" });
|
|
152
|
+
const env = await makeEnv();
|
|
153
|
+
process.env.DSH_RELAY_BUNDLED_RUNTIME = out; // 必须早于 boot:apply() 里就会用自带运行环境补装
|
|
154
|
+
const routes = boot(env.relayDir);
|
|
155
|
+
const { host, base } = await serve(routes);
|
|
156
|
+
try {
|
|
157
|
+
// 半装:入口在、依赖不在 —— 旧实现会报「运行环境: 已就绪」,然后插起来的守护秒退
|
|
158
|
+
await writeFile(path.join(env.relayDir, "dsh-setup.mjs"), HALF_INSTALL_ENTRY);
|
|
159
|
+
await writeFile(path.join(env.relayDir, ".dsh-config.json"),
|
|
160
|
+
JSON.stringify({ phone: "13800000000", password: "pw", api_url: "http://127.0.0.1:1" }));
|
|
161
|
+
const r = await (await fetch(`${base}/dsh-remote/bridge-status`)).json();
|
|
162
|
+
assert.equal(r.connect.runtimeReady, true, "自带运行环境补齐后必须报就绪");
|
|
163
|
+
assert.deepEqual(r.connect.runtimeMissing, [], "补齐后不得再缺文件");
|
|
164
|
+
assert.ok(existsSync(path.join(env.relayDir, "clients", "dsh-remote", "src", "lifecycle.mjs")),
|
|
165
|
+
"入口点名的 lifecycle.mjs 必须被补齐(这正是半装运行环境缺的那个文件)");
|
|
166
|
+
assert.ok(existsSync(path.join(env.relayDir, "node_modules", "ws", "package.json")), "依赖 ws 必须一起就位");
|
|
167
|
+
assert.equal((await readOrEmpty(path.join(env.relayDir, ".dsh-setup-version"))).trim(),
|
|
168
|
+
JSON.parse(readFileSync(PKG_JSON, "utf8")).version, "运行时版本标记必须等于插件版本(不再出现版本错配)");
|
|
169
|
+
assert.equal((await readOrEmpty(env.npxLog)).trim(), "",
|
|
170
|
+
"该走本地拷贝时**一次 npx 都不该调**(npx 正是 Windows 上的失败源)");
|
|
171
|
+
assert.match(await readOrEmpty(path.join(env.relayDir, ".dsh-setup-install.log")), /自带的运行环境就地补齐/);
|
|
172
|
+
} finally {
|
|
173
|
+
host.close(); routes.dispose(); await env.restore();
|
|
174
|
+
rmSync(out, { recursive: true, force: true });
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("自带运行环境本身不完整 → 老实回落到 npx(绝不假装补齐成功)", async () => {
|
|
179
|
+
const broken = await mkdtemp(path.join(os.tmpdir(), "dsh-runtime-broken-"));
|
|
180
|
+
const env = await makeEnv();
|
|
181
|
+
process.env.DSH_RELAY_BUNDLED_RUNTIME = broken; // 同上:apply() 先试自带、失败再落 npx
|
|
182
|
+
const routes = boot(env.relayDir);
|
|
183
|
+
const { host, base } = await serve(routes);
|
|
184
|
+
try {
|
|
185
|
+
// 「自带」目录只放入口、不放依赖 → 拷贝后自检必然失败
|
|
186
|
+
await writeFile(path.join(broken, "dsh-setup.mjs"), HALF_INSTALL_ENTRY);
|
|
187
|
+
await writeFile(path.join(env.relayDir, "dsh-setup.mjs"), HALF_INSTALL_ENTRY);
|
|
188
|
+
await writeFile(path.join(env.relayDir, ".dsh-config.json"),
|
|
189
|
+
JSON.stringify({ phone: "13800000000", password: "pw", api_url: "http://127.0.0.1:1" }));
|
|
190
|
+
await fetch(`${base}/dsh-remote/bridge-status`);
|
|
191
|
+
assert.ok(await waitFor(async () => (await readOrEmpty(env.npxLog)).includes("@mrrisega/dsh-remote")),
|
|
192
|
+
"自带运行环境不完整时必须回落 npx 补装,实际 npx 日志:" + (await readOrEmpty(env.npxLog)));
|
|
193
|
+
assert.match(await readOrEmpty(path.join(env.relayDir, ".dsh-setup-install.log")), /仍不完整/);
|
|
194
|
+
} finally {
|
|
195
|
+
host.close(); routes.dispose(); await env.restore();
|
|
196
|
+
rmSync(broken, { recursive: true, force: true });
|
|
197
|
+
}
|
|
198
|
+
});
|