@mrrisega/dsh-remote 0.6.7-beta.1 → 0.6.7-beta.3
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/README.md +15 -1
- package/clients/dsh-remote/dsh-bridge.mjs +31 -1
- package/dsh-setup.mjs +339 -25
- package/package.json +1 -1
- package/packages/dsh-remote-web/lib/client.js +37 -4
- package/packages/dsh-remote-web/lib/index.js +259 -24
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/test/plugin-link.test.mjs +400 -0
- package/packages/dsh-remote-web/test/self-manage.test.mjs +16 -7
- package/packages/dsh-remote-web/test/uninstall-runtime.test.mjs +44 -33
- package/packages/dsh-remote-web/test/uninstall-safety.test.mjs +379 -0
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 插件挂载(node_modules/<id> 链接)回归 —— 0.6.7-beta.2 新增。
|
|
3
|
+
*
|
|
4
|
+
* 背景(Windows 用户实测,2026-09-18):0.6.7-beta.1 的 `ensurePluginLinked` 用
|
|
5
|
+
* `fs.symlinkSync(target, path, "dir")` 建链接,并把 junction / 整目录拷贝两级兜底写在它的 `catch` 里。
|
|
6
|
+
* 在 **非特权进程 + 开发者模式关闭**(= 普通用户默认状态)下,`'dir'` 在 Windows 上
|
|
7
|
+
* **既不抛错也产不出可用链接**(实测:用户目录下留一个空目录,%TEMP% 下什么都不留;
|
|
8
|
+
* 同一位置两次运行一次报成功一次抛 UNKNOWN)。于是:
|
|
9
|
+
* · 报成功 → catch 不进入 → 两级兜底永不执行 → 留下空壳/什么都没有;
|
|
10
|
+
* · 报错 → catch 进入 → 但上一步已留空目录 → junction 撞 EEXIST → 只剩 cpSync 一支。
|
|
11
|
+
* 而调用方把返回值丢掉、patch 照写、还打印「✅ 插件已就绪」,最终
|
|
12
|
+
* dsh web 按裸包名解析到空目录 → `plugin tree failed to load` → **整个 dsh web 起不来**。
|
|
13
|
+
*
|
|
14
|
+
* ⚠️ 本文件存在的意义就是"上一版为什么漏测":老用例只断言"函数没抛错 / 返回 true",
|
|
15
|
+
* 而这里的断言对象是**通过 node_modules/<id> 这条路径到底能不能读到东西**,
|
|
16
|
+
* 并且用注入式 fs 代理**复现**了 Windows 那种"不抛错但落盘是空壳"的形态。
|
|
17
|
+
*/
|
|
18
|
+
import assert from "node:assert/strict";
|
|
19
|
+
import { execFile } from "node:child_process";
|
|
20
|
+
import fs from "node:fs";
|
|
21
|
+
import os from "node:os";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
import test from "node:test";
|
|
24
|
+
import { createRequire } from "node:module";
|
|
25
|
+
import { fileURLToPath } from "node:url";
|
|
26
|
+
|
|
27
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
const SETUP = path.join(HERE, "..", "..", "..", "dsh-setup.mjs");
|
|
29
|
+
const PLUGIN_SRC = path.join(HERE, "..");
|
|
30
|
+
const PLUGIN_ID = "dsh-remote-web";
|
|
31
|
+
const SETUP_SRC = fs.readFileSync(SETUP, "utf8");
|
|
32
|
+
|
|
33
|
+
// ─────────────── 源码内取函数(安装器是 CLI 脚本,import 会直接执行 main) ───────────────
|
|
34
|
+
|
|
35
|
+
/** 取出 dsh-setup.mjs 里某个函数的源码文本。 */
|
|
36
|
+
function fnSrc(name, nextMarker) {
|
|
37
|
+
const start = SETUP_SRC.indexOf(`function ${name}(`);
|
|
38
|
+
const end = SETUP_SRC.indexOf(nextMarker, start);
|
|
39
|
+
assert.ok(start > -1 && end > start, `应能从安装器源码里取出 ${name}`);
|
|
40
|
+
return SETUP_SRC.slice(start, end);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 用真实 fs/path 组装出被测函数(纯函数,可安全求值)。 */
|
|
44
|
+
function loadLinkFns() {
|
|
45
|
+
const src = fnSrc("pluginLinkUsable", "/**\n * 把插件挂到")
|
|
46
|
+
+ fnSrc("ensurePluginLinked", "/**\n * 独立验收:复刻 dsh 客户端模块系统的真实动作");
|
|
47
|
+
return new Function("fs", "path", "PLUGIN_ID", "PLUGIN_LEGACY_IDS",
|
|
48
|
+
`${src}; return { pluginLinkUsable, ensurePluginLinked };`)(fs, path, PLUGIN_ID, ["dsh-remote-ui"]);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 造一份"像真的"插件目录(package.json + lib/index.js 已是可用性判据的最低要求)。 */
|
|
52
|
+
function makePluginDir(root, { name = PLUGIN_ID, withEntry = true } = {}) {
|
|
53
|
+
fs.mkdirSync(path.join(root, "lib"), { recursive: true });
|
|
54
|
+
fs.writeFileSync(path.join(root, "package.json"), JSON.stringify({ name, version: "0.0.0-test" }));
|
|
55
|
+
if (withEntry) fs.writeFileSync(path.join(root, "lib", "index.js"), "export const x = 1;\n");
|
|
56
|
+
return root;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function tmpDir(prefix = "dsh-link-") {
|
|
60
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ─────────────── ① 可用性判据必须否定所有畸形形态 ───────────────
|
|
64
|
+
|
|
65
|
+
test("pluginLinkUsable:空目录/畸形链接一律判为不可用(不能只看'没抛错')", () => {
|
|
66
|
+
const { pluginLinkUsable } = loadLinkFns();
|
|
67
|
+
const root = tmpDir();
|
|
68
|
+
try {
|
|
69
|
+
const local = makePluginDir(path.join(root, "local"));
|
|
70
|
+
const nm = path.join(root, "node_modules", PLUGIN_ID);
|
|
71
|
+
fs.mkdirSync(path.dirname(nm), { recursive: true });
|
|
72
|
+
|
|
73
|
+
assert.equal(pluginLinkUsable(nm, local), false, "不存在 → 不可用");
|
|
74
|
+
|
|
75
|
+
// 这就是本 bug 的落盘形态:一个同名空目录
|
|
76
|
+
fs.mkdirSync(nm, { recursive: true });
|
|
77
|
+
assert.equal(pluginLinkUsable(nm, local), false, "★空目录必须判为不可用(老实现正是在这里当作成功)");
|
|
78
|
+
|
|
79
|
+
// 有 package.json 但 name 不对(旧的别名包残留 / 别的包)
|
|
80
|
+
fs.writeFileSync(path.join(nm, "package.json"), JSON.stringify({ name: "dsh-remote-ui" }));
|
|
81
|
+
fs.mkdirSync(path.join(nm, "lib"), { recursive: true });
|
|
82
|
+
fs.writeFileSync(path.join(nm, "lib", "index.js"), "x");
|
|
83
|
+
assert.equal(pluginLinkUsable(nm, local), false, "name 不匹配 → 不可用(面板注入靠裸包名)");
|
|
84
|
+
|
|
85
|
+
// name 对了但没有节点半入口
|
|
86
|
+
fs.writeFileSync(path.join(nm, "package.json"), JSON.stringify({ name: PLUGIN_ID }));
|
|
87
|
+
fs.rmSync(path.join(nm, "lib", "index.js"));
|
|
88
|
+
assert.equal(pluginLinkUsable(nm, local), false, "缺 lib/index.js → 不可用");
|
|
89
|
+
|
|
90
|
+
// 内容完整 + realpath 指回本地目录 → 可用
|
|
91
|
+
fs.writeFileSync(path.join(nm, "lib", "index.js"), fs.readFileSync(path.join(local, "lib", "index.js")));
|
|
92
|
+
fs.rmSync(nm, { recursive: true, force: true });
|
|
93
|
+
// 正向对照必须按平台取链接类型:Windows 非特权进程下 'dir' 本来就建不出来(正是本 bug),
|
|
94
|
+
// 拿它当"正确答案"会让用例在真 Windows 上自己失败。
|
|
95
|
+
if (process.platform === "win32") fs.symlinkSync(local, nm, "junction");
|
|
96
|
+
else fs.symlinkSync(local, nm, "dir");
|
|
97
|
+
assert.equal(pluginLinkUsable(nm, local), true, "真链接 → 可用");
|
|
98
|
+
|
|
99
|
+
// 整目录拷贝(内容一致)→ 也算可用
|
|
100
|
+
fs.rmSync(nm, { recursive: true, force: true });
|
|
101
|
+
fs.cpSync(local, nm, { recursive: true });
|
|
102
|
+
assert.equal(pluginLinkUsable(nm, local), true, "整目录拷贝(内容一致)→ 可用");
|
|
103
|
+
} finally {
|
|
104
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// ─────────────── ② 注入 Windows 的失败形态:不抛错但落盘是空壳 ───────────────
|
|
109
|
+
|
|
110
|
+
/** 造一个 fs 代理:可按需让某个策略"静默失败"(不抛错、也不产出任何东西)。 */
|
|
111
|
+
function fsProxy({ silentFail = [], emptyDirInstead = [] } = {}) {
|
|
112
|
+
return {
|
|
113
|
+
...fs,
|
|
114
|
+
symlinkSync(target, p, type) {
|
|
115
|
+
if (emptyDirInstead.includes(type)) { fs.mkdirSync(p, { recursive: true }); return; } // ← 本 bug 的落盘形态
|
|
116
|
+
if (silentFail.includes(type)) return; // ← 本 bug 的"报成功"
|
|
117
|
+
return fs.symlinkSync(target, p, type);
|
|
118
|
+
},
|
|
119
|
+
cpSync(...args) {
|
|
120
|
+
if (silentFail.includes("copy")) return;
|
|
121
|
+
return fs.cpSync(...args);
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function loadLinkFnsWithFs(fakeFs) {
|
|
127
|
+
const src = fnSrc("pluginLinkUsable", "/**\n * 把插件挂到")
|
|
128
|
+
+ fnSrc("ensurePluginLinked", "/**\n * 独立验收:复刻 dsh 客户端模块系统的真实动作");
|
|
129
|
+
return new Function("fs", "path", "PLUGIN_ID", "PLUGIN_LEGACY_IDS",
|
|
130
|
+
`${src}; return { pluginLinkUsable, ensurePluginLinked };`)(fakeFs, path, PLUGIN_ID, ["dsh-remote-ui"]);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
test("ensurePluginLinked:'不抛错但什么都没建' 时,兜底链必须继续生效(本 bug 的核心)", () => {
|
|
134
|
+
const root = tmpDir();
|
|
135
|
+
try {
|
|
136
|
+
const local = makePluginDir(path.join(root, "local"));
|
|
137
|
+
const profile = path.join(root, "profile");
|
|
138
|
+
fs.mkdirSync(profile, { recursive: true });
|
|
139
|
+
// 模拟 Windows:junction 报告成功但落盘什么都没有(老实现据此当作成功、直接 return)
|
|
140
|
+
const { ensurePluginLinked } = loadLinkFnsWithFs(fsProxy({ silentFail: ["junction"] }));
|
|
141
|
+
const changed = ensurePluginLinked(profile, local);
|
|
142
|
+
assert.equal(changed, true, "应当重建并报告发生了变更");
|
|
143
|
+
const nm = path.join(profile, "node_modules", PLUGIN_ID);
|
|
144
|
+
assert.ok(fs.existsSync(path.join(nm, "package.json")), "★兜底必须真的把内容落盘(老实现在这里留下空壳)");
|
|
145
|
+
assert.ok(fs.existsSync(path.join(nm, "lib", "index.js")), "★节点半入口必须可读");
|
|
146
|
+
assert.ok(fs.readdirSync(nm).length > 0, "node_modules/<id> 是空目录 = 本 bug 复现");
|
|
147
|
+
} finally {
|
|
148
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("ensurePluginLinked:上一支留下空目录残留时,下一支不得被 EEXIST 顶掉", () => {
|
|
153
|
+
const root = tmpDir();
|
|
154
|
+
try {
|
|
155
|
+
const local = makePluginDir(path.join(root, "local"));
|
|
156
|
+
const profile = path.join(root, "profile");
|
|
157
|
+
fs.mkdirSync(profile, { recursive: true });
|
|
158
|
+
const { ensurePluginLinked } = loadLinkFnsWithFs(fsProxy({ emptyDirInstead: ["junction"] }));
|
|
159
|
+
ensurePluginLinked(profile, local);
|
|
160
|
+
const nm = path.join(profile, "node_modules", PLUGIN_ID);
|
|
161
|
+
assert.ok(fs.existsSync(path.join(nm, "lib", "index.js")),
|
|
162
|
+
"★先留空目录再走兜底也必须成功(老实现这里撞 EEXIST,三级兜底退化成一级)");
|
|
163
|
+
} finally {
|
|
164
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("ensurePluginLinked:所有策略都失败时必须抛错,绝不返回成功", () => {
|
|
169
|
+
const root = tmpDir();
|
|
170
|
+
try {
|
|
171
|
+
const local = makePluginDir(path.join(root, "local"));
|
|
172
|
+
const profile = path.join(root, "profile");
|
|
173
|
+
fs.mkdirSync(profile, { recursive: true });
|
|
174
|
+
const { ensurePluginLinked } = loadLinkFnsWithFs(fsProxy({ silentFail: ["junction", "dir", "copy"] }));
|
|
175
|
+
assert.throws(() => ensurePluginLinked(profile, local), /插件挂载失败/,
|
|
176
|
+
"全失败必须抛错 —— 否则调用方会写出一个起不来的 profile");
|
|
177
|
+
} finally {
|
|
178
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("ensurePluginLinked:已经挂好时幂等返回 false(不重复重建)", () => {
|
|
183
|
+
const root = tmpDir();
|
|
184
|
+
try {
|
|
185
|
+
const local = makePluginDir(path.join(root, "local"));
|
|
186
|
+
const profile = path.join(root, "profile");
|
|
187
|
+
fs.mkdirSync(profile, { recursive: true });
|
|
188
|
+
const { ensurePluginLinked } = loadLinkFns();
|
|
189
|
+
assert.equal(ensurePluginLinked(profile, local), true, "首次应重建");
|
|
190
|
+
assert.equal(ensurePluginLinked(profile, local), false, "已就绪应幂等返回 false");
|
|
191
|
+
} finally {
|
|
192
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
// ─────────────── ③ 真起进程:装完必须真的能被裸包名解析到 ───────────────
|
|
197
|
+
|
|
198
|
+
/** 准备一个最小可用 profile(package.json + 空的 cordis.patch.yml)。 */
|
|
199
|
+
function makeProfile(root) {
|
|
200
|
+
const profile = path.join(root, "prof");
|
|
201
|
+
fs.mkdirSync(profile, { recursive: true });
|
|
202
|
+
fs.writeFileSync(path.join(profile, "package.json"), JSON.stringify({ name: "dsh-profile-web", version: "0.0.0" }, null, 2));
|
|
203
|
+
fs.writeFileSync(path.join(profile, "cordis.patch.yml"), "[]\n");
|
|
204
|
+
return profile;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** 在隔离 HOME 下跑安装器命令。 */
|
|
208
|
+
function runInstaller(args, env = {}) {
|
|
209
|
+
const home = env.HOME || fs.mkdtempSync(path.join(os.tmpdir(), "dsh-link-home-"));
|
|
210
|
+
return new Promise((resolve) => {
|
|
211
|
+
execFile(process.execPath, [SETUP, ...args], {
|
|
212
|
+
env: { ...process.env, HOME: home, DSH_RELAY_SKIP_SERVICE: "1", DSH_RELAY_DIR: path.join(home, ".dsh-remote"), ...env },
|
|
213
|
+
timeout: 60_000,
|
|
214
|
+
}, (err, stdout, stderr) => resolve({ code: err ? (err.code ?? 1) : 0, stdout, stderr }));
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** 复刻 dsh 的真实解析动作(浏览器半注入设置面板靠它)。 */
|
|
219
|
+
function resolveFromProfile(profile, spec) {
|
|
220
|
+
const req = createRequire(path.join(profile, "__probe.cjs"));
|
|
221
|
+
return req.resolve(spec);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
test("端到端:安装器装完插件后,node_modules/<id> 必须真的可读、可解析(不能只看退出码)", async () => {
|
|
225
|
+
const root = tmpDir();
|
|
226
|
+
try {
|
|
227
|
+
const profile = makeProfile(root);
|
|
228
|
+
const r = await runInstaller(["plugin"], { DSH_PROFILE_DIR: profile });
|
|
229
|
+
assert.equal(r.code, 0, `安装应成功:${r.stdout}\n${r.stderr}`);
|
|
230
|
+
|
|
231
|
+
const nm = path.join(profile, "node_modules", PLUGIN_ID);
|
|
232
|
+
// ① 落盘可读 —— Windows 非特权下老代码在这里就挂了
|
|
233
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(nm, "package.json"), "utf8"));
|
|
234
|
+
assert.equal(pkg.name, PLUGIN_ID);
|
|
235
|
+
assert.ok(fs.existsSync(path.join(nm, "lib", "index.js")), "节点半入口必须存在");
|
|
236
|
+
assert.ok(fs.readdirSync(nm).length > 0, "★空目录 = 本 bug 复现");
|
|
237
|
+
|
|
238
|
+
// ② 复刻 dsh 的解析动作
|
|
239
|
+
assert.ok(resolveFromProfile(profile, `${PLUGIN_ID}/package.json`).endsWith("package.json"));
|
|
240
|
+
assert.ok(resolveFromProfile(profile, PLUGIN_ID), "裸包名必须能解析到入口");
|
|
241
|
+
assert.equal(pkg.dsh.client.platform, "web", "浏览器半声明必须在(面板注入的前提)");
|
|
242
|
+
|
|
243
|
+
// ③ 激活行必须写进 patch
|
|
244
|
+
const patch = fs.readFileSync(path.join(profile, "cordis.patch.yml"), "utf8");
|
|
245
|
+
assert.match(patch, /dsh-remote-web/, "patch 里应有插件激活行");
|
|
246
|
+
} finally {
|
|
247
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
248
|
+
}
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("端到端:node_modules/<id> 已是空壳时,重新安装必须自愈", async () => {
|
|
252
|
+
const root = tmpDir();
|
|
253
|
+
try {
|
|
254
|
+
const profile = makeProfile(root);
|
|
255
|
+
// 复现中招现场:同名空目录
|
|
256
|
+
fs.mkdirSync(path.join(profile, "node_modules", PLUGIN_ID), { recursive: true });
|
|
257
|
+
const r = await runInstaller(["plugin"], { DSH_PROFILE_DIR: profile });
|
|
258
|
+
assert.equal(r.code, 0, `重装应自愈:${r.stdout}\n${r.stderr}`);
|
|
259
|
+
const nm = path.join(profile, "node_modules", PLUGIN_ID);
|
|
260
|
+
assert.ok(fs.existsSync(path.join(nm, "lib", "index.js")), "★空壳必须被重建(空目录不许当作已挂载)");
|
|
261
|
+
assert.ok(resolveFromProfile(profile, `${PLUGIN_ID}/package.json`));
|
|
262
|
+
} finally {
|
|
263
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
test("端到端:挂载失败必须中止安装,且**不写** patch(宁可不装,也不能写出起不来的 profile)", async (t) => {
|
|
268
|
+
if (typeof process.getuid === "function" && process.getuid() === 0) return; // root 不受权限位约束
|
|
269
|
+
// Windows 的 chmod 只改只读属性、**不限制目录写入**,制造不出"必然失败"的落盘环境。
|
|
270
|
+
// 该场景由上面三条注入式 fs 代理用例覆盖(含"全失败必须抛错")。
|
|
271
|
+
if (process.platform === "win32") { t.skip("Windows 无 POSIX 权限位语义"); return; }
|
|
272
|
+
const root = tmpDir();
|
|
273
|
+
try {
|
|
274
|
+
const profile = makeProfile(root);
|
|
275
|
+
const nmDir = path.join(profile, "node_modules");
|
|
276
|
+
fs.mkdirSync(nmDir, { recursive: true });
|
|
277
|
+
fs.chmodSync(nmDir, 0o500); // 只读:symlink 与 cpSync 都必然失败
|
|
278
|
+
try {
|
|
279
|
+
const r = await runInstaller(["plugin"], { DSH_PROFILE_DIR: profile });
|
|
280
|
+
assert.equal(r.code, 1, `应中止安装(非 0 退出):${r.stdout}\n${r.stderr}`);
|
|
281
|
+
assert.match(r.stderr, /已中止安装/, "必须明确告知已中止");
|
|
282
|
+
const patch = fs.readFileSync(path.join(profile, "cordis.patch.yml"), "utf8");
|
|
283
|
+
assert.ok(!/dsh-remote-web/.test(patch), "★绝不能写激活行:那会让 dsh web 起不来");
|
|
284
|
+
assert.ok(!/插件已就绪/.test(r.stdout), "不得谎报成功");
|
|
285
|
+
} finally {
|
|
286
|
+
fs.chmodSync(nmDir, 0o700);
|
|
287
|
+
}
|
|
288
|
+
} finally {
|
|
289
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// ─────────────── ④ 自救入口:repair ───────────────
|
|
294
|
+
|
|
295
|
+
test("repair:空壳现场能被修好,并打印挂载诊断三行", async () => {
|
|
296
|
+
const root = tmpDir();
|
|
297
|
+
try {
|
|
298
|
+
const profile = makeProfile(root);
|
|
299
|
+
await runInstaller(["plugin"], { DSH_PROFILE_DIR: profile });
|
|
300
|
+
// 破坏:把链接换成空壳(= 用户中招后的现场)
|
|
301
|
+
const nm = path.join(profile, "node_modules", PLUGIN_ID);
|
|
302
|
+
fs.rmSync(nm, { recursive: true, force: true });
|
|
303
|
+
fs.mkdirSync(nm, { recursive: true });
|
|
304
|
+
|
|
305
|
+
const r = await runInstaller(["repair"], { DSH_PROFILE_DIR: profile });
|
|
306
|
+
assert.equal(r.code, 0, `repair 应成功:${r.stdout}\n${r.stderr}`);
|
|
307
|
+
assert.match(r.stdout, /\[link\]/, "应打印链接诊断");
|
|
308
|
+
assert.match(r.stdout, /\[resolve\]/, "应打印解析诊断");
|
|
309
|
+
assert.match(r.stdout, /\[client\]/, "应打印客户端半声明诊断");
|
|
310
|
+
assert.match(r.stdout, /🛟/, "挂载不可用时应先摘掉激活行保命");
|
|
311
|
+
assert.ok(fs.existsSync(path.join(nm, "lib", "index.js")), "★应修好挂载");
|
|
312
|
+
assert.ok(resolveFromProfile(profile, `${PLUGIN_ID}/package.json`), "★应可解析");
|
|
313
|
+
assert.match(fs.readFileSync(path.join(profile, "cordis.patch.yml"), "utf8"), /dsh-remote-web/, "应恢复激活行");
|
|
314
|
+
} finally {
|
|
315
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test("repair:本地插件目录缺失时明确报错并给重装命令(不静默成功)", async () => {
|
|
320
|
+
const root = tmpDir();
|
|
321
|
+
try {
|
|
322
|
+
const profile = makeProfile(root);
|
|
323
|
+
const r = await runInstaller(["repair"], { DSH_PROFILE_DIR: profile });
|
|
324
|
+
assert.equal(r.code, 1, `无本地拷贝应失败:${r.stdout}\n${r.stderr}`);
|
|
325
|
+
assert.match(r.stderr, /请重新安装/, "必须给出可照抄的重装命令");
|
|
326
|
+
} finally {
|
|
327
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
// ─────────────── ⑤ 包管理器识别(Windows 上 `command -v` 恒失效) ───────────────
|
|
332
|
+
|
|
333
|
+
test("detectPackageManager:按声明文件识别,且代码里不得再用 command -v pnpm", () => {
|
|
334
|
+
const src = fnSrc("detectPackageManager", "/**\n * 市场装法下把插件包升到最新");
|
|
335
|
+
const detect = new Function("fs", "path", "spawnSync", "process", `${src}; return detectPackageManager;`)(
|
|
336
|
+
fs, path, () => ({ status: 1 }), process);
|
|
337
|
+
|
|
338
|
+
const root = tmpDir();
|
|
339
|
+
try {
|
|
340
|
+
const a = path.join(root, "a"); fs.mkdirSync(a);
|
|
341
|
+
fs.writeFileSync(path.join(a, "pnpm-workspace.yaml"), "packages: []\n");
|
|
342
|
+
assert.equal(detect(a), "pnpm", "有 pnpm-workspace.yaml → pnpm");
|
|
343
|
+
|
|
344
|
+
const b = path.join(root, "b"); fs.mkdirSync(b);
|
|
345
|
+
fs.writeFileSync(path.join(b, "package-lock.json"), "{}");
|
|
346
|
+
assert.equal(detect(b), "npm", "有 package-lock.json → npm");
|
|
347
|
+
|
|
348
|
+
const c = path.join(root, "c"); fs.mkdirSync(c);
|
|
349
|
+
assert.equal(detect(c), "pnpm", "都探测不到时兜底 pnpm(dsh profile 默认就是 pnpm)");
|
|
350
|
+
} finally {
|
|
351
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// 源码契约:不得再用 POSIX 内建 `command -v`(Windows 走 cmd.exe,永远命中 || 分支)
|
|
355
|
+
const codeLines = SETUP_SRC.split("\n").filter((l) => !/^\s*(\/\/|\*|\/\*)/.test(l));
|
|
356
|
+
assert.ok(!codeLines.some((l) => /command -v pnpm/.test(l)), "不得再用 command -v pnpm 探测");
|
|
357
|
+
assert.ok(/const pm = detectPackageManager\(profileDir\);/.test(SETUP_SRC), "升级路径必须用 detectPackageManager");
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
// ─────────────── ⑥ 源码契约:策略顺序与失败即中止 ───────────────
|
|
361
|
+
|
|
362
|
+
test("源码契约:Windows 首选 junction(不用 'dir'),且每支之间清理残留", () => {
|
|
363
|
+
const fn = fnSrc("ensurePluginLinked", "/**\n * 独立验收:复刻 dsh 客户端模块系统的真实动作");
|
|
364
|
+
const winBranch = fn.slice(fn.indexOf('process.platform === "win32"'), fn.indexOf('["copy"'));
|
|
365
|
+
assert.ok(winBranch.includes('"junction"'), "Windows 首选必须是 junction");
|
|
366
|
+
assert.ok(!/"dir"/.test(winBranch), "★Windows 分支不得再尝试 symlinkSync(...,'dir')");
|
|
367
|
+
assert.ok(/fs\.rmSync\(nmPlugin, \{ recursive: true, force: true \}\);\s*\}\s*catch/.test(fn) || /rmSync\(nmPlugin[^\n]*\n/.test(fn),
|
|
368
|
+
"每次尝试前必须清残留(否则下一支撞 EEXIST)");
|
|
369
|
+
assert.ok(/for \(const \[how, run\] of attempts\)/.test(fn), "必须是「逐支验证」的循环,而不是 try 嵌套 catch");
|
|
370
|
+
assert.ok(/throw new Error\(`插件挂载失败/.test(fn), "全部失败必须抛错");
|
|
371
|
+
// POSIX 行为保持不变(原生 symlink 无需特权)
|
|
372
|
+
const posixBranch = fn.slice(fn.indexOf(':\n [') >= 0 ? fn.indexOf(':\n [') : fn.indexOf('['), fn.indexOf("const failures"));
|
|
373
|
+
assert.ok(/"dir"/.test(posixBranch), "POSIX 仍用原生 symlink");
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
test("源码契约:调用方必须检查结果并在失败时中止(老代码丢返回值还报成功)", () => {
|
|
377
|
+
// 调用必须被 try/catch 包住(老代码是裸调用 + 丢返回值)
|
|
378
|
+
assert.ok(/try \{\s*\n\s*ensurePluginLinked\(profileDir, pluginLocalDir\);/.test(SETUP_SRC),
|
|
379
|
+
"ensurePluginLinked 必须在 try 里调用(否则失败会变成未捕获异常)");
|
|
380
|
+
const call = SETUP_SRC.slice(SETUP_SRC.indexOf("try {\n ensurePluginLinked(profileDir, pluginLocalDir);"), SETUP_SRC.indexOf("const r = ensurePatchActivation"));
|
|
381
|
+
assert.ok(/catch \(e\) \{[\s\S]*?process\.exit\(1\);/.test(call), "挂载失败必须 process.exit(1)");
|
|
382
|
+
// 顺序(只在 activateLocalCopy 函数体内看):挂载 → 独立验收 → 才 declarePluginDep / 写 patch
|
|
383
|
+
const body = SETUP_SRC.slice(
|
|
384
|
+
SETUP_SRC.indexOf("function activateLocalCopy("),
|
|
385
|
+
SETUP_SRC.indexOf("function convergePluginActivation("));
|
|
386
|
+
assert.ok(body.length > 0, "应能定位 activateLocalCopy");
|
|
387
|
+
const order = [
|
|
388
|
+
"ensurePluginLinked(profileDir, pluginLocalDir)",
|
|
389
|
+
"const probe = probePluginResolve(profileDir);",
|
|
390
|
+
"declarePluginDep(pkgFile);",
|
|
391
|
+
"ensurePatchActivation(patchFile",
|
|
392
|
+
];
|
|
393
|
+
let last = -1;
|
|
394
|
+
for (const marker of order) {
|
|
395
|
+
const i = body.indexOf(marker);
|
|
396
|
+
assert.ok(i > last, `activateLocalCopy 内的顺序必须是:挂载 → 验收 → 依赖 → 激活(${marker})`);
|
|
397
|
+
last = i;
|
|
398
|
+
}
|
|
399
|
+
assert.ok(/function probePluginResolve\(/.test(SETUP_SRC), "应有独立验收(复刻 dsh 的裸包名解析)");
|
|
400
|
+
});
|
|
@@ -155,6 +155,16 @@ test("残留 marker 自愈:记录进程已死的 marker 在插件启动时立
|
|
|
155
155
|
}
|
|
156
156
|
});
|
|
157
157
|
|
|
158
|
+
/** 等待条件成立:运行时清理现在是独立子进程,物理结果要等它跑完。 */
|
|
159
|
+
async function waitFor(fn, { timeout = 10000, step = 50 } = {}) {
|
|
160
|
+
const deadline = Date.now() + timeout;
|
|
161
|
+
for (;;) {
|
|
162
|
+
if (await fn()) return true;
|
|
163
|
+
if (Date.now() > deadline) return false;
|
|
164
|
+
await new Promise((r) => setTimeout(r, step));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
158
168
|
test("uninstall 路由:移除 include 块 + package.json 依赖/bundle + 本地目录(解锁市场卸载)", async () => {
|
|
159
169
|
const tempHome = await mkdtemp(path.join(os.tmpdir(), "dsh-ui-home-"));
|
|
160
170
|
const relayDir = path.join(tempHome, "relay");
|
|
@@ -189,13 +199,12 @@ test("uninstall 路由:移除 include 块 + package.json 依赖/bundle + 本
|
|
|
189
199
|
assert.equal(r.removedBundle, true, "应移除 dsh.profile.bundles 条目");
|
|
190
200
|
assert.equal(r.removedDir, true, "应删除本地插件目录与 node_modules 链接");
|
|
191
201
|
|
|
192
|
-
// 运行时/bridge
|
|
193
|
-
|
|
194
|
-
assert.equal(r.
|
|
195
|
-
|
|
196
|
-
assert.
|
|
197
|
-
assert.
|
|
198
|
-
assert.match(String(r.detail), /配置目录已清空/, "detail 应说明配置目录已清空");
|
|
202
|
+
// 运行时/bridge 清理:0.6.7-beta.3 起交给**独立子进程**(先回响应,慢活不阻塞事件循环),
|
|
203
|
+
// 所以这里断言响应契约 + 轮询等它的物理结果(隔离模式下只清空配置目录,绝不触碰真实 launchd 服务)
|
|
204
|
+
assert.equal(r.runtimeCleanup, "deferred", "运行时清理应由子进程接管");
|
|
205
|
+
const gone = await waitFor(() => !existsSync(relayDir));
|
|
206
|
+
assert.equal(gone, true, "relayDir 物理上应已不存在(子进程执行)");
|
|
207
|
+
assert.match(String(r.detail), /正在后台清理/, "detail 应说明后台清理");
|
|
199
208
|
assert.match(String(r.detail), /请重启 dsh web/, "detail 应提示重启生效");
|
|
200
209
|
|
|
201
210
|
const patch = await readFile(patchFile, "utf8");
|
|
@@ -2,8 +2,12 @@
|
|
|
2
2
|
// 背景:用户反馈卸载不干净——bridge 自启动服务(launchd com.dshremote.bridge / Linux dsh-bridge)
|
|
3
3
|
// 仍在运行、自启动 plist/unit 仍在、配置目录 ~/.dsh-remote(账号/设备密钥/.dsh-config.json/
|
|
4
4
|
// .harness-cookie.json/固化运行时 dsh-setup.mjs + clients 等)也还在。
|
|
5
|
-
//
|
|
6
|
-
//
|
|
5
|
+
// 0.6.7-beta.3 起卸载**分两段**(顺序是刻意的,见 uninstall 路由注释):
|
|
6
|
+
// ① 同步段:uninstallSelf(纯 fs、快)→ markUninstalled → 立刻回响应;
|
|
7
|
+
// ② 异步段:运行时清理(停自启动/杀残留/清配置目录)交给**独立子进程**执行。
|
|
8
|
+
// 为什么必须这样:同步段里任何一次 spawnSync 卡住(Windows 沙箱拦 schtasks,而 spawnSync 的
|
|
9
|
+
// timeout 在 Windows 上并不可靠),旧实现会让 uninstallSelf 永远不执行、整个 dsh web 僵死
|
|
10
|
+
// (端口在听、无响应)。因此这些用例改为:断言响应契约(runtimeCleanup)+ 轮询等待子进程的物理结果。
|
|
7
11
|
// 测试隔离(绝不触碰本机真实 launchd/systemd/进程):
|
|
8
12
|
// - 大多数用例设 DSH_RELAY_SKIP_SERVICE=1 → 跳过一切系统级操作,只验证配置目录清理与 profile 清理;
|
|
9
13
|
// - 「自然跳过」用例把 PATH 指向假 launchctl/pgrep/ps,完全接管系统命令后走无开关路径。
|
|
@@ -23,6 +27,18 @@ const PLUGIN_BLOCK = `# >>> dsh-remote-ui (managed by dsh-remote plugin; do not
|
|
|
23
27
|
# <<< dsh-remote-ui
|
|
24
28
|
`;
|
|
25
29
|
|
|
30
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
31
|
+
|
|
32
|
+
/** 等待条件成立:运行时清理现在是独立子进程,物理结果要等它跑完(而不是同步返回)。 */
|
|
33
|
+
async function waitFor(fn, { timeout = 10000, step = 50 } = {}) {
|
|
34
|
+
const deadline = Date.now() + timeout;
|
|
35
|
+
for (;;) {
|
|
36
|
+
if (await fn()) return true;
|
|
37
|
+
if (Date.now() > deadline) return false;
|
|
38
|
+
await sleep(step);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
26
42
|
/** 加载插件(要求调用方已把 process.env.HOME 指向 tempHome;profile 推导才落在 temp 而非真实 ~/.dsh)。 */
|
|
27
43
|
function boot(relayDir) {
|
|
28
44
|
const routes = new Map();
|
|
@@ -96,14 +112,11 @@ test("彻底卸载:配置目录整目录清空(含账号/密钥/固化运行
|
|
|
96
112
|
assert.equal(r.removedDep, true);
|
|
97
113
|
assert.equal(r.removedBundle, true);
|
|
98
114
|
assert.equal(r.removedDir, true, "profile 插件目录与 node_modules 链接应删除");
|
|
99
|
-
//
|
|
100
|
-
assert.equal(r.
|
|
101
|
-
assert.
|
|
102
|
-
|
|
103
|
-
assert.
|
|
104
|
-
assert.deepEqual(r.killedPids, []);
|
|
105
|
-
// 物理断言:relayDir 与其中全部残留都消失
|
|
106
|
-
assert.equal(existsSync(relayDir), false, "relayDir 目录应整体不存在");
|
|
115
|
+
// 运行时清理交给独立子进程(响应先回,慢活不再钉住事件循环)
|
|
116
|
+
assert.equal(r.runtimeCleanup, "deferred", "运行时清理必须由子进程接管(不得同步阻塞)");
|
|
117
|
+
assert.ok(r.cleanupLog, "应给出清理日志路径");
|
|
118
|
+
// 物理断言:等子进程跑完,relayDir 与其中全部残留都应消失
|
|
119
|
+
assert.ok(await waitFor(() => !existsSync(relayDir)), "relayDir 应被整目录清空(子进程执行)");
|
|
107
120
|
assert.equal(existsSync(path.join(relayDir, ".dsh-config.json")), false);
|
|
108
121
|
assert.equal(existsSync(path.join(relayDir, "dsh-setup.mjs")), false);
|
|
109
122
|
assert.equal(existsSync(path.join(relayDir, "clients")), false);
|
|
@@ -117,7 +130,7 @@ test("彻底卸载:配置目录整目录清空(含账号/密钥/固化运行
|
|
|
117
130
|
assert.equal(existsSync(profile), true, "dsh web profile 目录整体不得被删");
|
|
118
131
|
// 人类可读 detail
|
|
119
132
|
assert.match(String(r.detail), /插件引用与本地文件已移除/);
|
|
120
|
-
assert.match(String(r.detail),
|
|
133
|
+
assert.match(String(r.detail), /正在后台清理/);
|
|
121
134
|
assert.match(String(r.detail), /请重启 dsh web/);
|
|
122
135
|
} finally {
|
|
123
136
|
host.close();
|
|
@@ -145,13 +158,12 @@ test("彻底卸载边界:只装了插件、从未跑过 bridge → relayDir
|
|
|
145
158
|
const r = await (await fetch(`${base}/dsh-remote/self/uninstall`, { method: "POST" })).json();
|
|
146
159
|
assert.equal(r.ok, true);
|
|
147
160
|
assert.equal(r.removedPatch, true, "profile 里的插件引用应照常清理");
|
|
148
|
-
assert.equal(r.
|
|
149
|
-
assert.equal(r.stoppedService, false);
|
|
150
|
-
assert.equal(r.removedPlist, false);
|
|
151
|
-
assert.deepEqual(r.killedPids, []);
|
|
161
|
+
assert.equal(r.runtimeCleanup, "deferred");
|
|
152
162
|
const detail = String(r.detail);
|
|
153
163
|
assert.ok(!detail.includes("配置目录已清空"), "目录不存在时不应谎报已清空,实际: " + detail);
|
|
154
164
|
assert.match(detail, /请重启 dsh web/);
|
|
165
|
+
await sleep(800); // 等清理子进程跑完
|
|
166
|
+
assert.equal(existsSync(relayDir), false, "relayDir 本来就不存在 → 不得被凭空创建");
|
|
155
167
|
} finally {
|
|
156
168
|
host.close();
|
|
157
169
|
}
|
|
@@ -174,12 +186,13 @@ test("彻底卸载边界:什么都没有安装 → 全链路幂等返回 ok,
|
|
|
174
186
|
try {
|
|
175
187
|
const r = await (await fetch(`${base}/dsh-remote/self/uninstall`, { method: "POST" })).json();
|
|
176
188
|
assert.equal(r.ok, true);
|
|
177
|
-
assert.equal(r.
|
|
189
|
+
assert.equal(r.runtimeCleanup, "deferred");
|
|
178
190
|
assert.equal(r.removedPatch, false);
|
|
179
191
|
assert.equal(r.removedDep, false);
|
|
180
192
|
assert.equal(r.removedBundle, false);
|
|
181
|
-
assert.deepEqual(r.killedPids, []);
|
|
182
193
|
assert.match(String(r.detail), /请重启 dsh web/);
|
|
194
|
+
await sleep(800);
|
|
195
|
+
assert.equal(existsSync(relayDir), false, "什么都没有也不该凭空创建目录");
|
|
183
196
|
} finally {
|
|
184
197
|
host.close();
|
|
185
198
|
}
|
|
@@ -206,10 +219,9 @@ test("DSH_RELAY_SKIP_SERVICE 开关:置位时即便存在自启动 plist 也
|
|
|
206
219
|
try {
|
|
207
220
|
const r = await (await fetch(`${base}/dsh-remote/self/uninstall`, { method: "POST" })).json();
|
|
208
221
|
assert.equal(r.ok, true);
|
|
209
|
-
assert.equal(r.
|
|
210
|
-
assert.
|
|
211
|
-
assert.equal(
|
|
212
|
-
assert.equal(existsSync(relayDir), false);
|
|
222
|
+
assert.equal(r.runtimeCleanup, "deferred");
|
|
223
|
+
assert.ok(await waitFor(() => !existsSync(relayDir)), "配置目录清理不受开关影响");
|
|
224
|
+
assert.equal(existsSync(fakePlist), true, "开关置位 → plist 不得被删(子进程同样受开关约束)");
|
|
213
225
|
} finally {
|
|
214
226
|
host.close();
|
|
215
227
|
}
|
|
@@ -220,7 +232,7 @@ test("DSH_RELAY_SKIP_SERVICE 开关:置位时即便存在自启动 plist 也
|
|
|
220
232
|
}
|
|
221
233
|
});
|
|
222
234
|
|
|
223
|
-
test("
|
|
235
|
+
test("无开关(真实分支):自启动清理由独立子进程执行,且走 PATH 上的假 launchctl(不碰真实服务)", { skip: process.platform !== "darwin" }, async () => {
|
|
224
236
|
const tempHome = await mkdtemp(path.join(os.tmpdir(), "dsh-ui-rt-natural-"));
|
|
225
237
|
const prevHome = process.env.HOME;
|
|
226
238
|
const prevPath = process.env.PATH;
|
|
@@ -254,15 +266,13 @@ exit 0
|
|
|
254
266
|
try {
|
|
255
267
|
const r = await (await fetch(`${base}/dsh-remote/self/uninstall`, { method: "POST" })).json();
|
|
256
268
|
assert.equal(r.ok, true);
|
|
257
|
-
assert.equal(r.
|
|
258
|
-
|
|
259
|
-
assert.
|
|
260
|
-
assert.
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
const log = readFileSync(launchLog, "utf8");
|
|
265
|
-
assert.ok(!log.includes("bootout"), "未运行的服务不应触发 bootout,实际调用: " + log);
|
|
269
|
+
assert.equal(r.runtimeCleanup, "deferred", "无开关时同样走子进程");
|
|
270
|
+
// 等清理子进程跑完(它继承 HOME/PATH → plist 路径与 launchctl 都落在测试环境里)
|
|
271
|
+
assert.ok(await waitFor(() => !existsSync(plist)), "plist 应被清理子进程删除");
|
|
272
|
+
assert.ok(await waitFor(() => !existsSync(relayDir)), "配置目录应被清空");
|
|
273
|
+
// 关键安全性质:清理用的是 PATH 上的**假** launchctl —— 证明没有触碰任何真实服务
|
|
274
|
+
const log = existsSync(launchLog) ? readFileSync(launchLog, "utf8") : "";
|
|
275
|
+
assert.ok(log.includes("com.dshremote.bridge"), "应通过(假)launchctl 清理自启动,实际: " + log);
|
|
266
276
|
} finally {
|
|
267
277
|
host.close();
|
|
268
278
|
}
|
|
@@ -289,8 +299,9 @@ test("保护参数:relayDir 误配置成 dsh web profile 目录 → 绝不整
|
|
|
289
299
|
try {
|
|
290
300
|
const r = await (await fetch(`${base}/dsh-remote/self/uninstall`, { method: "POST" })).json();
|
|
291
301
|
assert.equal(r.ok, true);
|
|
292
|
-
assert.equal(r.
|
|
293
|
-
|
|
302
|
+
assert.equal(r.runtimeCleanup, "deferred");
|
|
303
|
+
await sleep(1000); // 等清理子进程跑完:它必须自己判断出"这是 profile,不许删"
|
|
304
|
+
assert.equal(existsSync(profile), true, "dsh web profile 必须存活(子进程也受同一条护栏约束)");
|
|
294
305
|
assert.equal(existsSync(path.join(profile, "package.json")), true);
|
|
295
306
|
assert.equal(existsSync(path.join(profile, "dsh-remote-ui-plugin")), false, "插件子目录应被 uninstallSelf 单独移除");
|
|
296
307
|
assert.equal(existsSync(path.join(profile, "node_modules", "dsh-remote-ui")), false);
|