@mrrisega/dsh-remote 0.8.0 → 0.8.1-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/package.json +1 -1
- package/packages/dsh-remote-web/lib/client.js +25 -1
- package/packages/dsh-remote-web/lib/index.js +130 -4
- package/packages/dsh-remote-web/lib/live-update.js +238 -0
- package/packages/dsh-remote-web/lib/self-update.js +1204 -0
- package/packages/dsh-remote-web/package.json +1 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/apply-pending-plugin.mjs +171 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/dingtalk-bot.mjs +34 -31
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/feishu-bot.mjs +26 -1
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/im-format.mjs +105 -0
- package/packages/dsh-remote-web/runtime/clients/dsh-remote/wecom-bot.mjs +54 -100
- package/packages/dsh-remote-web/runtime/dsh-setup.mjs +1 -1
- package/packages/dsh-remote-web/test/live-update.test.mjs +114 -0
- package/packages/dsh-remote-web/test/self-update.test.mjs +920 -0
|
@@ -0,0 +1,920 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* self-update.js —— 自升级核心模块的单元测试(`node --test`,**全程不联网**)。
|
|
4
|
+
*
|
|
5
|
+
* 为什么这些用例是"必须的"而不是"顺手加的":
|
|
6
|
+
* 升级是唯一会**覆盖用户正在用的插件目录**的操作。历史上一键更新坏掉(Windows 上 npx EINVAL)
|
|
7
|
+
* 让 79 台机器点了升级却永远装不上;而"装到一半"比"装不上"更糟 —— 面板没了、日志也说不清。
|
|
8
|
+
* 所以这里锁的都是"出事了会很难看"的分支:
|
|
9
|
+
* · 源顺序与回退(国内网络打不开 npmjs 是常态);
|
|
10
|
+
* · integrity / 包名 / 版本三关,任一不符都必须**在落盘前**停住;
|
|
11
|
+
* · tar 里的 `..`、绝对路径、符号链接/硬链接一律拒绝;
|
|
12
|
+
* · apply 失败必须回滚成"升级前的样子"(连 EBUSY 都不许把用户装好的目录删掉);
|
|
13
|
+
* · 同版本重复 apply 必须幂等(面板重复点击不该造出一堆 bak)。
|
|
14
|
+
*
|
|
15
|
+
* 所有 HTTP 都由注入的 fetchImpl 提供,tarball 由本地 tar 构造器生成 → 可离线、可重复。
|
|
16
|
+
*/
|
|
17
|
+
import assert from "node:assert/strict";
|
|
18
|
+
import crypto from "node:crypto";
|
|
19
|
+
import fs from "node:fs";
|
|
20
|
+
import os from "node:os";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import test from "node:test";
|
|
23
|
+
import zlib from "node:zlib";
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
DEFAULT_REGISTRIES,
|
|
27
|
+
ERROR_CODES,
|
|
28
|
+
PACKAGE_NAME,
|
|
29
|
+
PLUGIN_PACKAGE_NAME,
|
|
30
|
+
PLUGIN_SUBDIR,
|
|
31
|
+
applyStagedToProfile,
|
|
32
|
+
fetchTarballToStaging,
|
|
33
|
+
readInstalled,
|
|
34
|
+
resolveRegistryOrder,
|
|
35
|
+
resolveStagePayload,
|
|
36
|
+
rollback,
|
|
37
|
+
verifyInstalled,
|
|
38
|
+
} from "../lib/self-update.js";
|
|
39
|
+
|
|
40
|
+
const BLOCK = 512;
|
|
41
|
+
const REG_A = "https://registry.npmmirror.com";
|
|
42
|
+
const REG_B = "https://registry.npmjs.org";
|
|
43
|
+
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
// 本地 tar.gz 构造器(不依赖系统 tar,也不联网)
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
function octal(value, len) {
|
|
49
|
+
const digits = Math.max(1, len - 1);
|
|
50
|
+
const text = (Number(value) >>> 0).toString(8).padStart(digits, "0").slice(-digits);
|
|
51
|
+
return `${text}\0`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** 造一个 ustar 头(512 字节,含校验和)。 */
|
|
55
|
+
function tarHeader({ name, size = 0, type = "0", mode = 0o644, linkname = "" }) {
|
|
56
|
+
const header = Buffer.alloc(BLOCK, 0);
|
|
57
|
+
Buffer.from(name, "utf8").copy(header, 0, 0, Math.min(Buffer.byteLength(name), 100));
|
|
58
|
+
header.write(octal(mode, 8), 100, 8, "latin1");
|
|
59
|
+
header.write(octal(0, 8), 108, 8, "latin1"); // uid
|
|
60
|
+
header.write(octal(0, 8), 116, 8, "latin1"); // gid
|
|
61
|
+
header.write(octal(size, 12), 124, 12, "latin1");
|
|
62
|
+
header.write(octal(0, 12), 136, 12, "latin1"); // mtime
|
|
63
|
+
header.write(" ", 148, 8, "latin1"); // 校验和先填 8 个空格
|
|
64
|
+
header.write(type, 156, 1, "latin1");
|
|
65
|
+
if (linkname) header.write(linkname, 157, Math.min(Buffer.byteLength(linkname), 100), "latin1");
|
|
66
|
+
header.write("ustar", 257, 5, "latin1");
|
|
67
|
+
header.write("00", 263, 2, "latin1");
|
|
68
|
+
header.write("root", 265, 4, "latin1");
|
|
69
|
+
header.write("root", 297, 4, "latin1");
|
|
70
|
+
|
|
71
|
+
let sum = 0;
|
|
72
|
+
for (const byte of header) sum += byte;
|
|
73
|
+
header.write(`${sum.toString(8).padStart(6, "0")}\0 `, 148, 8, "latin1");
|
|
74
|
+
return header;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** @param {{name:string, data?:string|Buffer, type?:string, linkname?:string}[]} entries */
|
|
78
|
+
function makeTgz(entries) {
|
|
79
|
+
const parts = [];
|
|
80
|
+
for (const entry of entries) {
|
|
81
|
+
const type = entry.type || "0";
|
|
82
|
+
const data = entry.data == null ? Buffer.alloc(0) : Buffer.from(entry.data);
|
|
83
|
+
const payload = type === "0" ? data : Buffer.alloc(0);
|
|
84
|
+
parts.push(tarHeader({ name: entry.name, size: payload.length, type, linkname: entry.linkname }));
|
|
85
|
+
if (payload.length > 0) {
|
|
86
|
+
parts.push(payload);
|
|
87
|
+
const pad = (BLOCK - (payload.length % BLOCK)) % BLOCK;
|
|
88
|
+
if (pad > 0) parts.push(Buffer.alloc(pad));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
parts.push(Buffer.alloc(BLOCK * 2)); // 两个全零块 = tar 结束
|
|
92
|
+
return zlib.gzipSync(Buffer.concat(parts));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* npm tarball 的真实布局:根包 `@mrrisega/dsh-remote` + `packages/dsh-remote-web/`。
|
|
97
|
+
* 插件目录(profile 里被替换的那个)是**子包**,不是根包 —— 这一点是 2026-11-11 真机实测
|
|
98
|
+
* 才确认的(根包 package.json 里 name 是 @mrrisega/dsh-remote,main 是 dsh-setup.mjs)。
|
|
99
|
+
*/
|
|
100
|
+
function packageEntries({
|
|
101
|
+
version = "0.8.0",
|
|
102
|
+
rootVersion = version,
|
|
103
|
+
pluginVersion = version,
|
|
104
|
+
rootName = PACKAGE_NAME,
|
|
105
|
+
pluginName = PLUGIN_PACKAGE_NAME,
|
|
106
|
+
extra = [],
|
|
107
|
+
} = {}) {
|
|
108
|
+
return [
|
|
109
|
+
{ name: "package/package.json", data: JSON.stringify({ name: rootName, version: rootVersion, main: "dsh-setup.mjs" }) },
|
|
110
|
+
{ name: "package/dsh-setup.mjs", data: "#!/usr/bin/env node\n// setup\n" },
|
|
111
|
+
{
|
|
112
|
+
name: `package/${PLUGIN_SUBDIR}/package.json`,
|
|
113
|
+
data: JSON.stringify({ name: pluginName, version: pluginVersion, main: "lib/index.js", type: "module" }),
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
name: `package/${PLUGIN_SUBDIR}/lib/index.js`,
|
|
117
|
+
data: `export const version = ${JSON.stringify(pluginVersion)};\n`,
|
|
118
|
+
},
|
|
119
|
+
{ name: `package/${PLUGIN_SUBDIR}/runtime/marker.txt`, data: `${pluginVersion}\n` },
|
|
120
|
+
...extra,
|
|
121
|
+
];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function sha512b64(buffer) {
|
|
125
|
+
return `sha512-${crypto.createHash("sha512").update(buffer).digest("base64")}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function sha1hex(buffer) {
|
|
129
|
+
return crypto.createHash("sha1").update(buffer).digest("hex");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// 假 registry(注入 fetchImpl)
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
/** 最小响应替身:只要 ok/status/headers.get/json/body 齐了,模块就能跑。 */
|
|
137
|
+
function fakeResponse({ status = 200, jsonBody, jsonError = false, body, chunks, headers = {} }) {
|
|
138
|
+
const buf = body == null ? null : Buffer.isBuffer(body) ? body : Buffer.from(body);
|
|
139
|
+
const lower = {};
|
|
140
|
+
for (const [k, v] of Object.entries(headers)) lower[k.toLowerCase()] = String(v);
|
|
141
|
+
return {
|
|
142
|
+
ok: status >= 200 && status < 300,
|
|
143
|
+
status,
|
|
144
|
+
headers: { get: (name) => (String(name).toLowerCase() in lower ? lower[String(name).toLowerCase()] : null) },
|
|
145
|
+
async json() {
|
|
146
|
+
if (jsonError) throw new SyntaxError("Unexpected token '<', \"<html>\" is not valid JSON");
|
|
147
|
+
return jsonBody;
|
|
148
|
+
},
|
|
149
|
+
async arrayBuffer() {
|
|
150
|
+
return buf;
|
|
151
|
+
},
|
|
152
|
+
body: chunks
|
|
153
|
+
? (async function* () {
|
|
154
|
+
for (const chunk of chunks) yield Buffer.from(chunk);
|
|
155
|
+
})()
|
|
156
|
+
: buf
|
|
157
|
+
? (async function* () {
|
|
158
|
+
yield buf;
|
|
159
|
+
})()
|
|
160
|
+
: null,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* @param {object} spec
|
|
166
|
+
* @param {string} spec.base
|
|
167
|
+
* @param {string} [spec.version]
|
|
168
|
+
* @param {object[]} [spec.entries] tarball 内容
|
|
169
|
+
* @param {Buffer} [spec.tarball] 直接给 tarball 字节
|
|
170
|
+
* @param {string} [spec.integrity] 覆盖(用于造"校验不符")
|
|
171
|
+
* @param {"throw"} [spec.mode] 连不上
|
|
172
|
+
* @param {number} [spec.metaStatus]
|
|
173
|
+
* @param {number} [spec.tarballStatus]
|
|
174
|
+
* @param {number} [spec.maxBytesHint] 伪造 content-length
|
|
175
|
+
*/
|
|
176
|
+
function makeRegistry(spec) {
|
|
177
|
+
const {
|
|
178
|
+
base,
|
|
179
|
+
version = "0.8.0",
|
|
180
|
+
entries,
|
|
181
|
+
tarball,
|
|
182
|
+
integrity,
|
|
183
|
+
mode,
|
|
184
|
+
metaStatus,
|
|
185
|
+
tarballStatus,
|
|
186
|
+
maxBytesHint,
|
|
187
|
+
tag = "latest",
|
|
188
|
+
name = PACKAGE_NAME,
|
|
189
|
+
rootVersion,
|
|
190
|
+
pluginVersion,
|
|
191
|
+
pluginName,
|
|
192
|
+
} = spec;
|
|
193
|
+
|
|
194
|
+
const buffer =
|
|
195
|
+
tarball ||
|
|
196
|
+
makeTgz(
|
|
197
|
+
entries ||
|
|
198
|
+
packageEntries({
|
|
199
|
+
version,
|
|
200
|
+
rootName: name,
|
|
201
|
+
rootVersion: rootVersion === undefined ? undefined : rootVersion,
|
|
202
|
+
pluginVersion: pluginVersion === undefined ? undefined : pluginVersion,
|
|
203
|
+
pluginName: pluginName === undefined ? undefined : pluginName,
|
|
204
|
+
}),
|
|
205
|
+
);
|
|
206
|
+
const tarballUrl = `${base}/${PACKAGE_NAME}/-/${PACKAGE_NAME.split("/")[1]}-${version}.tgz`;
|
|
207
|
+
const doc = {
|
|
208
|
+
name: PACKAGE_NAME,
|
|
209
|
+
"dist-tags": { latest: version, [tag]: version },
|
|
210
|
+
versions: {
|
|
211
|
+
[version]: {
|
|
212
|
+
name,
|
|
213
|
+
version,
|
|
214
|
+
dist: {
|
|
215
|
+
tarball: tarballUrl,
|
|
216
|
+
integrity: integrity === undefined ? sha512b64(buffer) : integrity,
|
|
217
|
+
shasum: sha1hex(buffer),
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
base,
|
|
225
|
+
version,
|
|
226
|
+
buffer,
|
|
227
|
+
tarballUrl,
|
|
228
|
+
doc,
|
|
229
|
+
mode,
|
|
230
|
+
metaStatus,
|
|
231
|
+
tarballStatus,
|
|
232
|
+
maxBytesHint,
|
|
233
|
+
get url() {
|
|
234
|
+
return tarballUrl;
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function makeFetch(regs) {
|
|
240
|
+
const calls = [];
|
|
241
|
+
const impl = async (url) => {
|
|
242
|
+
calls.push(url);
|
|
243
|
+
const reg = regs.find((r) => url.startsWith(`${r.base}/`));
|
|
244
|
+
if (!reg) {
|
|
245
|
+
const err = new TypeError(`fetch failed: 未注册的源 ${url}`);
|
|
246
|
+
err.code = "ECONNREFUSED";
|
|
247
|
+
throw err;
|
|
248
|
+
}
|
|
249
|
+
if (reg.mode === "throw") {
|
|
250
|
+
const err = new TypeError(`fetch failed(connect ECONNREFUSED ${reg.base})`);
|
|
251
|
+
err.code = "ECONNREFUSED";
|
|
252
|
+
throw err;
|
|
253
|
+
}
|
|
254
|
+
if (url === reg.tarballUrl) {
|
|
255
|
+
if (reg.tarballStatus) return fakeResponse({ status: reg.tarballStatus, body: "denied" });
|
|
256
|
+
const headers = {};
|
|
257
|
+
if (reg.maxBytesHint) headers["content-length"] = String(reg.maxBytesHint);
|
|
258
|
+
return fakeResponse({ body: reg.buffer, headers });
|
|
259
|
+
}
|
|
260
|
+
if (reg.metaStatus) return fakeResponse({ status: reg.metaStatus, body: JSON.stringify({ error: "Not found" }) });
|
|
261
|
+
return fakeResponse({ jsonBody: reg.doc, headers: { "content-type": "application/json" } });
|
|
262
|
+
};
|
|
263
|
+
impl.calls = calls;
|
|
264
|
+
return impl;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
// 临时目录 / 安装夹具
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
function tempDir(t, prefix = "dsh-selfupdate-") {
|
|
272
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
273
|
+
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
|
274
|
+
return dir;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** 造一个"已安装"的插件目录(package.json + lib/index.js + 一个内容标记文件)。 */
|
|
278
|
+
function writeInstall(dir, version, { name = PLUGIN_PACKAGE_NAME } = {}) {
|
|
279
|
+
fs.mkdirSync(path.join(dir, "lib"), { recursive: true });
|
|
280
|
+
fs.writeFileSync(path.join(dir, "package.json"), JSON.stringify({ name, version, main: "lib/index.js" }, null, 2));
|
|
281
|
+
fs.writeFileSync(path.join(dir, "lib", "index.js"), `export const version = ${JSON.stringify(version)};\n`);
|
|
282
|
+
fs.writeFileSync(path.join(dir, "marker.txt"), `${version}\n`);
|
|
283
|
+
return dir;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** 造一个"解包后的 tarball"staging:`<root>/package/…`(真实发布产物形状)。 */
|
|
287
|
+
function writeTarballStage(root, { version = "0.8.0", rootVersion, pluginVersion, pluginName } = {}) {
|
|
288
|
+
const stageRoot = path.join(root, "package");
|
|
289
|
+
const pluginDir = path.join(stageRoot, ...PLUGIN_SUBDIR.split("/"));
|
|
290
|
+
writeInstall(pluginDir, pluginVersion || version, { name: pluginName || PLUGIN_PACKAGE_NAME });
|
|
291
|
+
fs.writeFileSync(
|
|
292
|
+
path.join(stageRoot, "package.json"),
|
|
293
|
+
JSON.stringify({ name: PACKAGE_NAME, version: rootVersion || version, main: "dsh-setup.mjs" }),
|
|
294
|
+
);
|
|
295
|
+
fs.writeFileSync(path.join(stageRoot, "dsh-setup.mjs"), "#!/usr/bin/env node\n// setup\n");
|
|
296
|
+
return stageRoot;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function bakDirs(root) {
|
|
300
|
+
return fs.readdirSync(root).filter((n) => n.includes(".bak-") || n.includes(".failed-"));
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const FIXED_REGISTRIES = [REG_A, REG_B];
|
|
304
|
+
const ONLY_A = [REG_A];
|
|
305
|
+
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
// 用例:registry 顺序
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
test("resolveRegistryOrder:默认 npmmirror 优先、官方源兜底", () => {
|
|
311
|
+
assert.deepEqual(resolveRegistryOrder({}), [...DEFAULT_REGISTRIES]);
|
|
312
|
+
assert.deepEqual(resolveRegistryOrder({}), [REG_A, REG_B]);
|
|
313
|
+
// 空串 / 空白 / 全是非法值 → 回落默认,而不是"一个源都没有"
|
|
314
|
+
assert.deepEqual(resolveRegistryOrder({ DSH_REMOTE_REGISTRY: " " }), [REG_A, REG_B]);
|
|
315
|
+
assert.deepEqual(resolveRegistryOrder({ DSH_REMOTE_REGISTRY: "ftp://x,file:///y" }), [REG_A, REG_B]);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
test("resolveRegistryOrder:DSH_REMOTE_REGISTRY 逗号覆盖(去空格/去尾斜杠/去重保序)", () => {
|
|
319
|
+
const env = { DSH_REMOTE_REGISTRY: ` https://r.example.com/ , https://registry.npmjs.org , https://r.example.com ` };
|
|
320
|
+
assert.deepEqual(resolveRegistryOrder(env), ["https://r.example.com", REG_B]);
|
|
321
|
+
assert.deepEqual(resolveRegistryOrder({ DSH_REMOTE_REGISTRY: "https://only.example" }), ["https://only.example"]);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
// ---------------------------------------------------------------------------
|
|
325
|
+
// 用例:正常下载解包
|
|
326
|
+
// ---------------------------------------------------------------------------
|
|
327
|
+
|
|
328
|
+
test("正常:latest 从首个源下载 → integrity 校验 → 解包到 <stageDir>/package/", async (t) => {
|
|
329
|
+
const dir = tempDir(t);
|
|
330
|
+
const stageDir = path.join(dir, "stage");
|
|
331
|
+
const regA = makeRegistry({ base: REG_A, version: "0.8.0" });
|
|
332
|
+
const fetchImpl = makeFetch([regA]);
|
|
333
|
+
|
|
334
|
+
const res = await fetchTarballToStaging({ tag: "latest", stageDir, registries: ONLY_A, fetchImpl });
|
|
335
|
+
|
|
336
|
+
assert.equal(res.ok, true);
|
|
337
|
+
assert.equal(res.version, "0.8.0");
|
|
338
|
+
assert.equal(res.registry, REG_A);
|
|
339
|
+
assert.equal(res.stageRoot, path.join(stageDir, "package"));
|
|
340
|
+
assert.equal(res.bytes, regA.buffer.length);
|
|
341
|
+
assert.ok(res.ms >= 0);
|
|
342
|
+
assert.equal(res.integrity.checked, true);
|
|
343
|
+
assert.equal(res.integrity.algo, "sha512");
|
|
344
|
+
assert.equal(res.attempts.length, 1);
|
|
345
|
+
assert.deepEqual(res.attempts[0].ok, true);
|
|
346
|
+
|
|
347
|
+
// 真的落到磁盘了:根包在 stageRoot,插件包在 payloadRoot(会被 apply 装走的那层)
|
|
348
|
+
const rootPkg = JSON.parse(fs.readFileSync(path.join(res.stageRoot, "package.json"), "utf8"));
|
|
349
|
+
assert.equal(rootPkg.name, PACKAGE_NAME);
|
|
350
|
+
assert.equal(rootPkg.version, "0.8.0");
|
|
351
|
+
assert.equal(res.payloadRoot, path.join(res.stageRoot, ...PLUGIN_SUBDIR.split("/")));
|
|
352
|
+
const pluginPkg = JSON.parse(fs.readFileSync(path.join(res.payloadRoot, "package.json"), "utf8"));
|
|
353
|
+
assert.equal(pluginPkg.name, PLUGIN_PACKAGE_NAME);
|
|
354
|
+
assert.equal(pluginPkg.version, "0.8.0");
|
|
355
|
+
assert.ok(fs.existsSync(path.join(res.payloadRoot, "lib", "index.js")));
|
|
356
|
+
assert.ok(fs.existsSync(path.join(res.stageRoot, "dsh-setup.mjs")));
|
|
357
|
+
assert.ok(res.files >= 5);
|
|
358
|
+
// 只请求了两条 URL:packument(作用域名按 npm 规范编码)+ tarball
|
|
359
|
+
assert.deepEqual(fetchImpl.calls, [
|
|
360
|
+
`${REG_A}/@mrrisega%2fdsh-remote/latest`,
|
|
361
|
+
regA.tarballUrl,
|
|
362
|
+
]);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test("tag=beta 走 dist-tags.beta,精确 version 也接受", async (t) => {
|
|
366
|
+
const dir = tempDir(t);
|
|
367
|
+
const reg = makeRegistry({ base: REG_A, version: "0.7.12-beta.3", tag: "beta" });
|
|
368
|
+
const fetchImpl = makeFetch([reg]);
|
|
369
|
+
|
|
370
|
+
const byTag = await fetchTarballToStaging({
|
|
371
|
+
tag: "beta",
|
|
372
|
+
stageDir: path.join(dir, "a"),
|
|
373
|
+
registries: ONLY_A,
|
|
374
|
+
fetchImpl,
|
|
375
|
+
});
|
|
376
|
+
assert.equal(byTag.version, "0.7.12-beta.3");
|
|
377
|
+
assert.equal(byTag.requested, "beta");
|
|
378
|
+
|
|
379
|
+
const byVersion = await fetchTarballToStaging({
|
|
380
|
+
version: "0.7.12-beta.3",
|
|
381
|
+
stageDir: path.join(dir, "b"),
|
|
382
|
+
registries: ONLY_A,
|
|
383
|
+
fetchImpl,
|
|
384
|
+
});
|
|
385
|
+
assert.equal(byVersion.version, "0.7.12-beta.3");
|
|
386
|
+
assert.equal(byVersion.requested, "0.7.12-beta.3");
|
|
387
|
+
|
|
388
|
+
// 显式 version 优先于 tag(面板会带着默认 tag="latest" 传用户选的版本)
|
|
389
|
+
const preferVersion = await fetchTarballToStaging({
|
|
390
|
+
version: "0.7.12-beta.3",
|
|
391
|
+
tag: "latest",
|
|
392
|
+
stageDir: path.join(dir, "c"),
|
|
393
|
+
registries: ONLY_A,
|
|
394
|
+
fetchImpl,
|
|
395
|
+
});
|
|
396
|
+
assert.equal(preferVersion.version, "0.7.12-beta.3");
|
|
397
|
+
assert.equal(preferVersion.requested, "0.7.12-beta.3");
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
// ---------------------------------------------------------------------------
|
|
401
|
+
// 用例:源回退 / 全失败 / HTTP 状态
|
|
402
|
+
// ---------------------------------------------------------------------------
|
|
403
|
+
|
|
404
|
+
test("registry 回退:npmmirror 连不上 → 自动换 npmjs,并在 attempts 里留证据", async (t) => {
|
|
405
|
+
const dir = tempDir(t);
|
|
406
|
+
const stageDir = path.join(dir, "stage");
|
|
407
|
+
const dead = makeRegistry({ base: REG_A, mode: "throw" });
|
|
408
|
+
const alive = makeRegistry({ base: REG_B, version: "0.8.0" });
|
|
409
|
+
const fetchImpl = makeFetch([dead, alive]);
|
|
410
|
+
|
|
411
|
+
const res = await fetchTarballToStaging({ tag: "latest", stageDir, registries: FIXED_REGISTRIES, fetchImpl });
|
|
412
|
+
|
|
413
|
+
assert.equal(res.ok, true);
|
|
414
|
+
assert.equal(res.registry, REG_B);
|
|
415
|
+
assert.equal(res.attempts.length, 2);
|
|
416
|
+
assert.equal(res.attempts[0].registry, REG_A);
|
|
417
|
+
assert.equal(res.attempts[0].ok, false);
|
|
418
|
+
assert.equal(res.attempts[0].code, ERROR_CODES.REGISTRY_UNREACHABLE);
|
|
419
|
+
assert.equal(res.attempts[1].ok, true);
|
|
420
|
+
assert.ok(res.attempts[0].ms >= 0 && res.attempts[1].ms >= 0);
|
|
421
|
+
// staging 只保留最终成功的那个包(换源前会清空)
|
|
422
|
+
assert.equal(JSON.parse(fs.readFileSync(path.join(stageDir, "package", "package.json"), "utf8")).version, "0.8.0");
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
test("全部源失败:抛 REGISTRY_UNREACHABLE,带逐个源的 attempts 与耗时", async (t) => {
|
|
426
|
+
const dir = tempDir(t);
|
|
427
|
+
const fetchImpl = makeFetch([
|
|
428
|
+
makeRegistry({ base: REG_A, mode: "throw" }),
|
|
429
|
+
makeRegistry({ base: REG_B, mode: "throw" }),
|
|
430
|
+
]);
|
|
431
|
+
|
|
432
|
+
await assert.rejects(
|
|
433
|
+
() => fetchTarballToStaging({ tag: "latest", stageDir: path.join(dir, "stage"), registries: FIXED_REGISTRIES, fetchImpl }),
|
|
434
|
+
(err) => {
|
|
435
|
+
assert.equal(err.code, ERROR_CODES.REGISTRY_UNREACHABLE);
|
|
436
|
+
assert.equal(err.attempts.length, 2);
|
|
437
|
+
assert.ok(err.attempts.every((a) => a.ok === false));
|
|
438
|
+
assert.ok(err.attempts.every((a) => typeof a.error === "string" && a.error.includes(ERROR_CODES.REGISTRY_UNREACHABLE)));
|
|
439
|
+
return true;
|
|
440
|
+
},
|
|
441
|
+
);
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
test("源返回 404 → 稳定错误码 HTTP_404(可据此区分「包不存在」与「连不上」)", async (t) => {
|
|
445
|
+
const dir = tempDir(t);
|
|
446
|
+
const fetchImpl = makeFetch([makeRegistry({ base: REG_A, metaStatus: 404 })]);
|
|
447
|
+
|
|
448
|
+
await assert.rejects(
|
|
449
|
+
() => fetchTarballToStaging({ version: "9.9.9", stageDir: path.join(dir, "stage"), registries: [REG_A], fetchImpl }),
|
|
450
|
+
(err) => err.code === "HTTP_404" && err.status === 404,
|
|
451
|
+
);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test("tarball 下载 500 → HTTP_500,且会去试下一个源", async (t) => {
|
|
455
|
+
const dir = tempDir(t);
|
|
456
|
+
const bad = makeRegistry({ base: REG_A, tarballStatus: 500 });
|
|
457
|
+
const good = makeRegistry({ base: REG_B });
|
|
458
|
+
const fetchImpl = makeFetch([bad, good]);
|
|
459
|
+
|
|
460
|
+
const res = await fetchTarballToStaging({
|
|
461
|
+
tag: "latest",
|
|
462
|
+
stageDir: path.join(dir, "stage"),
|
|
463
|
+
registries: FIXED_REGISTRIES,
|
|
464
|
+
fetchImpl,
|
|
465
|
+
});
|
|
466
|
+
assert.equal(res.registry, REG_B);
|
|
467
|
+
assert.equal(res.attempts[0].code, "HTTP_500");
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
// ---------------------------------------------------------------------------
|
|
471
|
+
// 用例:三关校验(integrity / 版本 / 包名)
|
|
472
|
+
// ---------------------------------------------------------------------------
|
|
473
|
+
|
|
474
|
+
test("integrity 不符:拒绝且不落盘(staging 被清空)", async (t) => {
|
|
475
|
+
const dir = tempDir(t);
|
|
476
|
+
const stageDir = path.join(dir, "stage");
|
|
477
|
+
const reg = makeRegistry({ base: REG_A, integrity: `sha512-${Buffer.alloc(64, 7).toString("base64")}` });
|
|
478
|
+
const fetchImpl = makeFetch([reg]);
|
|
479
|
+
|
|
480
|
+
await assert.rejects(
|
|
481
|
+
() => fetchTarballToStaging({ tag: "latest", stageDir, registries: ONLY_A, fetchImpl }),
|
|
482
|
+
(err) => {
|
|
483
|
+
assert.equal(err.code, ERROR_CODES.INTEGRITY_MISMATCH);
|
|
484
|
+
assert.equal(err.algo, "sha512");
|
|
485
|
+
assert.equal(err.attempts.length, 1);
|
|
486
|
+
return true;
|
|
487
|
+
},
|
|
488
|
+
);
|
|
489
|
+
assert.equal(fs.existsSync(stageDir), false, "校验失败的包不能留在 staging 里");
|
|
490
|
+
});
|
|
491
|
+
|
|
492
|
+
test("版本不符:根包或插件包的 version 与源解析出的版本不一致 → VERSION_MISMATCH", async (t) => {
|
|
493
|
+
const dir = tempDir(t);
|
|
494
|
+
// 源声称 0.8.0,根包里却写 0.9.0(镜像错版/投毒都会长这样)
|
|
495
|
+
const badRoot = makeRegistry({ base: REG_A, version: "0.8.0", entries: packageEntries({ version: "0.9.0" }) });
|
|
496
|
+
await assert.rejects(
|
|
497
|
+
() =>
|
|
498
|
+
fetchTarballToStaging({
|
|
499
|
+
tag: "latest",
|
|
500
|
+
stageDir: path.join(dir, "root"),
|
|
501
|
+
registries: ONLY_A,
|
|
502
|
+
fetchImpl: makeFetch([badRoot]),
|
|
503
|
+
}),
|
|
504
|
+
(err) => {
|
|
505
|
+
assert.equal(err.code, ERROR_CODES.VERSION_MISMATCH);
|
|
506
|
+
assert.equal(err.expected, "0.8.0");
|
|
507
|
+
assert.equal(err.actual, "0.9.0");
|
|
508
|
+
return true;
|
|
509
|
+
},
|
|
510
|
+
);
|
|
511
|
+
assert.equal(fs.existsSync(path.join(dir, "root")), false);
|
|
512
|
+
|
|
513
|
+
// 根包对、插件包版本不对(发布脚本漏改子包版本)→ 一样拒绝
|
|
514
|
+
const badPlugin = makeRegistry({
|
|
515
|
+
base: REG_A,
|
|
516
|
+
version: "0.8.0",
|
|
517
|
+
entries: packageEntries({ version: "0.8.0", pluginVersion: "0.9.0" }),
|
|
518
|
+
});
|
|
519
|
+
await assert.rejects(
|
|
520
|
+
() =>
|
|
521
|
+
fetchTarballToStaging({
|
|
522
|
+
tag: "latest",
|
|
523
|
+
stageDir: path.join(dir, "plugin"),
|
|
524
|
+
registries: ONLY_A,
|
|
525
|
+
fetchImpl: makeFetch([badPlugin]),
|
|
526
|
+
}),
|
|
527
|
+
(err) => err.code === ERROR_CODES.VERSION_MISMATCH && err.actual === "0.9.0",
|
|
528
|
+
);
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
test("name 不符:根包名不对 / 插件包名不对 都拒绝", async (t) => {
|
|
532
|
+
const dir = tempDir(t);
|
|
533
|
+
const evilRoot = makeRegistry({ base: REG_A, entries: packageEntries({ version: "0.8.0", rootName: "evil-package" }) });
|
|
534
|
+
await assert.rejects(
|
|
535
|
+
() =>
|
|
536
|
+
fetchTarballToStaging({
|
|
537
|
+
tag: "latest",
|
|
538
|
+
stageDir: path.join(dir, "root"),
|
|
539
|
+
registries: ONLY_A,
|
|
540
|
+
fetchImpl: makeFetch([evilRoot]),
|
|
541
|
+
}),
|
|
542
|
+
(err) => err.code === ERROR_CODES.NAME_MISMATCH && err.actual === "evil-package",
|
|
543
|
+
);
|
|
544
|
+
|
|
545
|
+
const evilPlugin = makeRegistry({
|
|
546
|
+
base: REG_A,
|
|
547
|
+
entries: packageEntries({ version: "0.8.0", pluginName: "evil-plugin" }),
|
|
548
|
+
});
|
|
549
|
+
await assert.rejects(
|
|
550
|
+
() =>
|
|
551
|
+
fetchTarballToStaging({
|
|
552
|
+
tag: "latest",
|
|
553
|
+
stageDir: path.join(dir, "plugin"),
|
|
554
|
+
registries: ONLY_A,
|
|
555
|
+
fetchImpl: makeFetch([evilPlugin]),
|
|
556
|
+
}),
|
|
557
|
+
(err) => err.code === ERROR_CODES.NAME_MISMATCH && err.actual === "evil-plugin",
|
|
558
|
+
);
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
test("resolveStagePayload:根包自动定位子包;两层都不匹配才抛 NAME_MISMATCH", (t) => {
|
|
562
|
+
const root = tempDir(t);
|
|
563
|
+
const stageRoot = writeTarballStage(root, { version: "0.8.0" });
|
|
564
|
+
assert.equal(resolveStagePayload({ stageRoot }), path.join(stageRoot, ...PLUGIN_SUBDIR.split("/")));
|
|
565
|
+
|
|
566
|
+
const payloadOnly = writeInstall(path.join(root, "payload"), "0.8.0");
|
|
567
|
+
assert.equal(resolveStagePayload({ stageRoot: payloadOnly }), payloadOnly);
|
|
568
|
+
|
|
569
|
+
const alien = writeInstall(path.join(root, "alien"), "0.8.0", { name: "not-ours" });
|
|
570
|
+
assert.throws(
|
|
571
|
+
() => resolveStagePayload({ stageRoot: alien }),
|
|
572
|
+
(err) => err.code === ERROR_CODES.NAME_MISMATCH && err.actual === "not-ours",
|
|
573
|
+
);
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
// ---------------------------------------------------------------------------
|
|
577
|
+
// 用例:tar 安全
|
|
578
|
+
// ---------------------------------------------------------------------------
|
|
579
|
+
|
|
580
|
+
test("路径穿越(../)被拒绝:BAD_TARBALL / detail=traversal,目标目录外无文件", async (t) => {
|
|
581
|
+
const dir = tempDir(t);
|
|
582
|
+
const stageDir = path.join(dir, "stage");
|
|
583
|
+
const reg = makeRegistry({
|
|
584
|
+
base: REG_A,
|
|
585
|
+
entries: [
|
|
586
|
+
...packageEntries({ version: "0.8.0" }),
|
|
587
|
+
{ name: "package/../../evil.txt", data: "pwned\n" },
|
|
588
|
+
],
|
|
589
|
+
});
|
|
590
|
+
const fetchImpl = makeFetch([reg]);
|
|
591
|
+
|
|
592
|
+
await assert.rejects(
|
|
593
|
+
() => fetchTarballToStaging({ tag: "latest", stageDir, registries: ONLY_A, fetchImpl }),
|
|
594
|
+
(err) => err.code === ERROR_CODES.BAD_TARBALL && err.detail === "traversal",
|
|
595
|
+
);
|
|
596
|
+
assert.equal(fs.existsSync(path.join(dir, "evil.txt")), false);
|
|
597
|
+
assert.equal(fs.existsSync(path.join(path.dirname(dir), "evil.txt")), false);
|
|
598
|
+
});
|
|
599
|
+
|
|
600
|
+
test("绝对路径条目被拒绝(BAD_TARBALL)", async (t) => {
|
|
601
|
+
const dir = tempDir(t);
|
|
602
|
+
const reg = makeRegistry({
|
|
603
|
+
base: REG_A,
|
|
604
|
+
entries: [...packageEntries({ version: "0.8.0" }), { name: "/tmp/evil-abs.txt", data: "pwned\n" }],
|
|
605
|
+
});
|
|
606
|
+
await assert.rejects(
|
|
607
|
+
() =>
|
|
608
|
+
fetchTarballToStaging({
|
|
609
|
+
tag: "latest",
|
|
610
|
+
stageDir: path.join(dir, "stage"),
|
|
611
|
+
registries: ONLY_A,
|
|
612
|
+
fetchImpl: makeFetch([reg]),
|
|
613
|
+
}),
|
|
614
|
+
(err) => err.code === ERROR_CODES.BAD_TARBALL,
|
|
615
|
+
);
|
|
616
|
+
assert.equal(fs.existsSync("/tmp/evil-abs.txt"), false);
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
test("符号链接 / 硬链接条目被拒绝(BAD_TARBALL / detail=unsafe_entry)", async (t) => {
|
|
620
|
+
const dir = tempDir(t);
|
|
621
|
+
const fetchImpl = makeFetch([
|
|
622
|
+
makeRegistry({
|
|
623
|
+
base: REG_A,
|
|
624
|
+
entries: [
|
|
625
|
+
...packageEntries({ version: "0.8.0" }),
|
|
626
|
+
{ name: "package/link-to-etc", type: "2", linkname: "/etc/passwd" },
|
|
627
|
+
],
|
|
628
|
+
}),
|
|
629
|
+
]);
|
|
630
|
+
await assert.rejects(
|
|
631
|
+
() => fetchTarballToStaging({ tag: "latest", stageDir: path.join(dir, "a"), registries: ONLY_A, fetchImpl }),
|
|
632
|
+
(err) => err.code === ERROR_CODES.BAD_TARBALL && err.detail === "unsafe_entry",
|
|
633
|
+
);
|
|
634
|
+
|
|
635
|
+
const hardlinkFetch = makeFetch([
|
|
636
|
+
makeRegistry({
|
|
637
|
+
base: REG_A,
|
|
638
|
+
entries: [
|
|
639
|
+
...packageEntries({ version: "0.8.0" }),
|
|
640
|
+
{ name: "package/hard", type: "1", linkname: "package/lib/index.js" },
|
|
641
|
+
],
|
|
642
|
+
}),
|
|
643
|
+
]);
|
|
644
|
+
await assert.rejects(
|
|
645
|
+
() =>
|
|
646
|
+
fetchTarballToStaging({
|
|
647
|
+
tag: "latest",
|
|
648
|
+
stageDir: path.join(dir, "b"),
|
|
649
|
+
registries: ONLY_A,
|
|
650
|
+
fetchImpl: hardlinkFetch,
|
|
651
|
+
}),
|
|
652
|
+
(err) => err.code === ERROR_CODES.BAD_TARBALL && err.detail === "unsafe_entry",
|
|
653
|
+
);
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
test("布局异常(文件不在 package/ 下)被拒绝,不会写到 staging 根目录", async (t) => {
|
|
657
|
+
const dir = tempDir(t);
|
|
658
|
+
const fetchImpl = makeFetch([
|
|
659
|
+
makeRegistry({ base: REG_A, entries: [{ name: "package.json", data: JSON.stringify({ name: PACKAGE_NAME, version: "0.8.0" }) }] }),
|
|
660
|
+
]);
|
|
661
|
+
await assert.rejects(
|
|
662
|
+
() =>
|
|
663
|
+
fetchTarballToStaging({
|
|
664
|
+
tag: "latest",
|
|
665
|
+
stageDir: path.join(dir, "stage"),
|
|
666
|
+
registries: ONLY_A,
|
|
667
|
+
fetchImpl,
|
|
668
|
+
}),
|
|
669
|
+
(err) => err.code === ERROR_CODES.BAD_TARBALL && err.detail === "bad_layout",
|
|
670
|
+
);
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
test("下载超过大小上限 → TARBALL_TOO_BIG(看 content-length 就先失败)", async (t) => {
|
|
674
|
+
const dir = tempDir(t);
|
|
675
|
+
const fetchImpl = makeFetch([makeRegistry({ base: REG_A, maxBytesHint: 8 * 1024 * 1024 })]);
|
|
676
|
+
await assert.rejects(
|
|
677
|
+
() =>
|
|
678
|
+
fetchTarballToStaging({
|
|
679
|
+
tag: "latest",
|
|
680
|
+
stageDir: path.join(dir, "stage"),
|
|
681
|
+
registries: ONLY_A,
|
|
682
|
+
fetchImpl,
|
|
683
|
+
maxBytes: 1024,
|
|
684
|
+
}),
|
|
685
|
+
(err) => err.code === ERROR_CODES.TARBALL_TOO_BIG && err.bytes === 8 * 1024 * 1024,
|
|
686
|
+
);
|
|
687
|
+
});
|
|
688
|
+
|
|
689
|
+
// ---------------------------------------------------------------------------
|
|
690
|
+
// 用例:apply 原子性 / 幂等 / 回滚
|
|
691
|
+
// ---------------------------------------------------------------------------
|
|
692
|
+
|
|
693
|
+
test("apply 正常:从根包 staging 自动取子包 → 旧目录变 backup、新版本就位", (t) => {
|
|
694
|
+
const root = tempDir(t);
|
|
695
|
+
const target = writeInstall(path.join(root, "plugin"), "0.7.0");
|
|
696
|
+
// 真实形状:stageRoot 是根包解出来的 package/,插件在 packages/dsh-remote-web/
|
|
697
|
+
const stageRoot = writeTarballStage(path.join(root, "stage"), { version: "0.8.0" });
|
|
698
|
+
|
|
699
|
+
const res = applyStagedToProfile({ stageRoot, targetDir: target });
|
|
700
|
+
|
|
701
|
+
assert.equal(res.ok, true);
|
|
702
|
+
assert.equal(res.applied, true);
|
|
703
|
+
assert.equal(res.fromVersion, "0.7.0");
|
|
704
|
+
assert.equal(res.toVersion, "0.8.0");
|
|
705
|
+
assert.equal(res.payloadRoot, path.join(stageRoot, ...PLUGIN_SUBDIR.split("/")));
|
|
706
|
+
assert.equal(readInstalled({ targetDir: target }).name, PLUGIN_PACKAGE_NAME);
|
|
707
|
+
assert.ok(res.backupDir && fs.existsSync(res.backupDir));
|
|
708
|
+
assert.equal(readInstalled({ targetDir: res.backupDir }).version, "0.7.0");
|
|
709
|
+
assert.equal(readInstalled({ targetDir: target }).version, "0.8.0");
|
|
710
|
+
assert.equal(fs.readFileSync(path.join(target, "marker.txt"), "utf8").trim(), "0.8.0");
|
|
711
|
+
assert.equal(verifyInstalled({ targetDir: target, expectedVersion: "0.8.0" }).version, "0.8.0");
|
|
712
|
+
// 只有插件子目录被搬走,根包其它内容留在 staging(不会被塞进 profile)
|
|
713
|
+
assert.equal(fs.existsSync(res.payloadRoot), false);
|
|
714
|
+
assert.ok(fs.existsSync(path.join(stageRoot, "dsh-setup.mjs")));
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
test("apply 原子性:stage → target 的 rename 失败 → 自动回滚,目标保持原样", (t) => {
|
|
718
|
+
const root = tempDir(t);
|
|
719
|
+
const target = writeInstall(path.join(root, "plugin"), "0.7.0");
|
|
720
|
+
const stageRoot = writeInstall(path.join(root, "stage", "package"), "0.8.0");
|
|
721
|
+
const before = fs.readdirSync(target).sort();
|
|
722
|
+
|
|
723
|
+
// 只让「stage → target」这一步失败(模拟权限/占用),其余 rename 走真实 fs
|
|
724
|
+
const failingFs = Object.create(fs);
|
|
725
|
+
failingFs.renameSync = (from, to) => {
|
|
726
|
+
if (path.resolve(from) === path.resolve(stageRoot)) {
|
|
727
|
+
const err = new Error(`EACCES: permission denied, rename '${from}' -> '${to}'`);
|
|
728
|
+
err.code = "EACCES";
|
|
729
|
+
throw err;
|
|
730
|
+
}
|
|
731
|
+
return fs.renameSync(from, to);
|
|
732
|
+
};
|
|
733
|
+
|
|
734
|
+
let failure = null;
|
|
735
|
+
try {
|
|
736
|
+
applyStagedToProfile({ stageRoot, targetDir: target, fsImpl: failingFs });
|
|
737
|
+
assert.fail("apply 应该失败");
|
|
738
|
+
} catch (err) {
|
|
739
|
+
failure = err;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
assert.equal(failure.code, ERROR_CODES.APPLY_FAILED);
|
|
743
|
+
assert.equal(failure.rollback.ok, true, `回滚必须成功:${JSON.stringify(failure.rollback)}`);
|
|
744
|
+
assert.equal(failure.rollback.restored, true);
|
|
745
|
+
// 目标目录:内容、版本、文件清单都与升级前一致
|
|
746
|
+
assert.equal(readInstalled({ targetDir: target }).version, "0.7.0");
|
|
747
|
+
assert.equal(fs.readFileSync(path.join(target, "marker.txt"), "utf8").trim(), "0.7.0");
|
|
748
|
+
assert.deepEqual(fs.readdirSync(target).sort(), before);
|
|
749
|
+
// 没有留下 bak-* / failed-* 垃圾
|
|
750
|
+
assert.deepEqual(bakDirs(root), []);
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
test("TARGET_BUSY:旧目录被占用时不动磁盘(不能把用户装好的目录删了)", (t) => {
|
|
754
|
+
const root = tempDir(t);
|
|
755
|
+
const target = writeInstall(path.join(root, "plugin"), "0.7.0");
|
|
756
|
+
const stageRoot = writeInstall(path.join(root, "stage", "package"), "0.8.0");
|
|
757
|
+
|
|
758
|
+
const busyFs = Object.create(fs);
|
|
759
|
+
busyFs.renameSync = (from, to) => {
|
|
760
|
+
if (path.resolve(from) === path.resolve(target)) {
|
|
761
|
+
const err = new Error("EBUSY: resource busy or locked");
|
|
762
|
+
err.code = "EBUSY";
|
|
763
|
+
throw err;
|
|
764
|
+
}
|
|
765
|
+
return fs.renameSync(from, to);
|
|
766
|
+
};
|
|
767
|
+
|
|
768
|
+
assert.throws(
|
|
769
|
+
() => applyStagedToProfile({ stageRoot, targetDir: target, fsImpl: busyFs }),
|
|
770
|
+
(err) => err.code === ERROR_CODES.TARGET_BUSY && err.rollback.restored === true,
|
|
771
|
+
);
|
|
772
|
+
|
|
773
|
+
assert.equal(fs.existsSync(target), true, "目标目录不能被删");
|
|
774
|
+
assert.equal(readInstalled({ targetDir: target }).version, "0.7.0");
|
|
775
|
+
assert.equal(fs.readFileSync(path.join(target, "marker.txt"), "utf8").trim(), "0.7.0");
|
|
776
|
+
assert.deepEqual(bakDirs(root), []);
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
test("同版本幂等:已装 0.8.0 再 apply 0.8.0 → noop,不动磁盘、不造 bak", (t) => {
|
|
780
|
+
const root = tempDir(t);
|
|
781
|
+
const target = writeInstall(path.join(root, "plugin"), "0.8.0");
|
|
782
|
+
const stageRoot = writeInstall(path.join(root, "stage", "package"), "0.8.0");
|
|
783
|
+
const markerBefore = fs.readFileSync(path.join(target, "marker.txt"), "utf8");
|
|
784
|
+
const inoBefore = fs.statSync(target).ino;
|
|
785
|
+
|
|
786
|
+
const res = applyStagedToProfile({ stageRoot, targetDir: target });
|
|
787
|
+
|
|
788
|
+
assert.equal(res.ok, true);
|
|
789
|
+
assert.equal(res.applied, false);
|
|
790
|
+
assert.equal(res.noop, true);
|
|
791
|
+
assert.equal(res.fromVersion, "0.8.0");
|
|
792
|
+
assert.equal(res.backupDir, null);
|
|
793
|
+
assert.equal(fs.existsSync(stageRoot), true, "noop 不该消费 staging");
|
|
794
|
+
assert.equal(fs.readFileSync(path.join(target, "marker.txt"), "utf8"), markerBefore);
|
|
795
|
+
assert.equal(fs.statSync(target).ino, inoBefore, "noop 不该替换目录 inode");
|
|
796
|
+
assert.deepEqual(bakDirs(root), []);
|
|
797
|
+
|
|
798
|
+
// force=true 才真的重装
|
|
799
|
+
const forced = applyStagedToProfile({ stageRoot, targetDir: target, force: true });
|
|
800
|
+
assert.equal(forced.applied, true);
|
|
801
|
+
assert.equal(forced.fromVersion, "0.8.0");
|
|
802
|
+
assert.ok(forced.backupDir);
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
test("apply 校验:stage 的 name/version 不符时直接拒绝(不碰目标目录)", (t) => {
|
|
806
|
+
const root = tempDir(t);
|
|
807
|
+
const target = writeInstall(path.join(root, "plugin"), "0.7.0");
|
|
808
|
+
const badName = writeInstall(path.join(root, "bad-name", "package"), "0.8.0", { name: "evil-package" });
|
|
809
|
+
const badVersion = writeInstall(path.join(root, "bad-version", "package"), "0.8.1");
|
|
810
|
+
|
|
811
|
+
assert.throws(
|
|
812
|
+
() => applyStagedToProfile({ stageRoot: badName, targetDir: target }),
|
|
813
|
+
(err) => err.code === ERROR_CODES.NAME_MISMATCH,
|
|
814
|
+
);
|
|
815
|
+
assert.throws(
|
|
816
|
+
() => applyStagedToProfile({ stageRoot: badVersion, targetDir: target, expectedVersion: "0.8.0" }),
|
|
817
|
+
(err) => err.code === ERROR_CODES.VERSION_MISMATCH,
|
|
818
|
+
);
|
|
819
|
+
assert.equal(readInstalled({ targetDir: target }).version, "0.7.0");
|
|
820
|
+
assert.deepEqual(bakDirs(root), []);
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
test("keepBackup=false:成功后不保留 bak", (t) => {
|
|
824
|
+
const root = tempDir(t);
|
|
825
|
+
const target = writeInstall(path.join(root, "plugin"), "0.7.0");
|
|
826
|
+
const stageRoot = writeInstall(path.join(root, "stage", "package"), "0.8.0");
|
|
827
|
+
|
|
828
|
+
const res = applyStagedToProfile({ stageRoot, targetDir: target, keepBackup: false });
|
|
829
|
+
assert.equal(res.applied, true);
|
|
830
|
+
assert.equal(res.backupDir, null);
|
|
831
|
+
assert.deepEqual(bakDirs(root), []);
|
|
832
|
+
assert.equal(readInstalled({ targetDir: target }).version, "0.8.0");
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
test("rollback:用 bak 还原旧版本,新版本挪到 .failed-* 留证", (t) => {
|
|
836
|
+
const root = tempDir(t);
|
|
837
|
+
const target = writeInstall(path.join(root, "plugin"), "0.7.0");
|
|
838
|
+
const stageRoot = writeInstall(path.join(root, "stage", "package"), "0.8.0");
|
|
839
|
+
|
|
840
|
+
const applied = applyStagedToProfile({ stageRoot, targetDir: target });
|
|
841
|
+
assert.equal(readInstalled({ targetDir: target }).version, "0.8.0");
|
|
842
|
+
|
|
843
|
+
const rb = rollback({ backupDir: applied.backupDir, targetDir: target });
|
|
844
|
+
|
|
845
|
+
assert.equal(rb.ok, true);
|
|
846
|
+
assert.equal(rb.restored, true);
|
|
847
|
+
assert.equal(rb.version, "0.7.0");
|
|
848
|
+
assert.equal(readInstalled({ targetDir: target }).version, "0.7.0");
|
|
849
|
+
assert.equal(fs.readFileSync(path.join(target, "marker.txt"), "utf8").trim(), "0.7.0");
|
|
850
|
+
assert.ok(rb.movedAside && fs.existsSync(rb.movedAside));
|
|
851
|
+
assert.equal(readInstalled({ targetDir: rb.movedAside }).version, "0.8.0");
|
|
852
|
+
// 备份被消费掉(rename 走),不会出现"还原了但 bak 还占着"的歧义
|
|
853
|
+
assert.equal(fs.existsSync(rb.consumedBackup), false);
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
test("rollback 失败路径:备份不存在 → ROLLBACK_FAILED(不误删当前版本)", (t) => {
|
|
857
|
+
const root = tempDir(t);
|
|
858
|
+
const target = writeInstall(path.join(root, "plugin"), "0.8.0");
|
|
859
|
+
assert.throws(
|
|
860
|
+
() => rollback({ backupDir: path.join(root, "nope.bak-1"), targetDir: target }),
|
|
861
|
+
(err) => err.code === ERROR_CODES.ROLLBACK_FAILED && err.detail === "no_backup",
|
|
862
|
+
);
|
|
863
|
+
assert.equal(readInstalled({ targetDir: target }).version, "0.8.0");
|
|
864
|
+
});
|
|
865
|
+
|
|
866
|
+
// ---------------------------------------------------------------------------
|
|
867
|
+
// 用例:verifyInstalled / readInstalled
|
|
868
|
+
// ---------------------------------------------------------------------------
|
|
869
|
+
|
|
870
|
+
test("verifyInstalled:未安装 / 版本不符 / 包名不符 各自报对应错误码", (t) => {
|
|
871
|
+
const root = tempDir(t);
|
|
872
|
+
const empty = path.join(root, "empty");
|
|
873
|
+
fs.mkdirSync(empty, { recursive: true });
|
|
874
|
+
|
|
875
|
+
assert.throws(() => verifyInstalled({ targetDir: empty }), (err) => err.code === ERROR_CODES.NOT_INSTALLED);
|
|
876
|
+
assert.throws(
|
|
877
|
+
() => verifyInstalled({ targetDir: path.join(root, "missing") }),
|
|
878
|
+
(err) => err.code === ERROR_CODES.NOT_INSTALLED,
|
|
879
|
+
);
|
|
880
|
+
|
|
881
|
+
const target = writeInstall(path.join(root, "plugin"), "0.8.0");
|
|
882
|
+
assert.equal(verifyInstalled({ targetDir: target }).version, "0.8.0");
|
|
883
|
+
assert.throws(
|
|
884
|
+
() => verifyInstalled({ targetDir: target, expectedVersion: "0.8.1" }),
|
|
885
|
+
(err) => err.code === ERROR_CODES.VERSION_MISMATCH,
|
|
886
|
+
);
|
|
887
|
+
|
|
888
|
+
const wrongName = writeInstall(path.join(root, "other"), "0.8.0", { name: "other-pkg" });
|
|
889
|
+
assert.throws(
|
|
890
|
+
() => verifyInstalled({ targetDir: wrongName }),
|
|
891
|
+
(err) => err.code === ERROR_CODES.NAME_MISMATCH,
|
|
892
|
+
);
|
|
893
|
+
|
|
894
|
+
const info = readInstalled({ targetDir: target });
|
|
895
|
+
assert.equal(info.installed, true);
|
|
896
|
+
assert.equal(info.version, "0.8.0");
|
|
897
|
+
assert.equal(readInstalled({ targetDir: empty }).installed, false);
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
// ---------------------------------------------------------------------------
|
|
901
|
+
// 用例:一次完整的"下载 → 校验 → apply"
|
|
902
|
+
// ---------------------------------------------------------------------------
|
|
903
|
+
|
|
904
|
+
test("端到端(注入 fetch):npmmirror 下载 0.8.0 → apply 到 0.7.0 的目录 → verify", async (t) => {
|
|
905
|
+
const root = tempDir(t);
|
|
906
|
+
const target = writeInstall(path.join(root, "plugin"), "0.7.0");
|
|
907
|
+
const stageDir = path.join(root, "stage");
|
|
908
|
+
const reg = makeRegistry({ base: REG_A, version: "0.8.0" });
|
|
909
|
+
const fetchImpl = makeFetch([reg]);
|
|
910
|
+
|
|
911
|
+
const dl = await fetchTarballToStaging({ tag: "latest", stageDir, registries: ONLY_A, fetchImpl });
|
|
912
|
+
const applied = applyStagedToProfile({ stageRoot: dl.stageRoot, targetDir: target });
|
|
913
|
+
|
|
914
|
+
assert.equal(applied.fromVersion, "0.7.0");
|
|
915
|
+
assert.equal(applied.toVersion, "0.8.0");
|
|
916
|
+
assert.equal(applied.payloadRoot, dl.payloadRoot);
|
|
917
|
+
assert.equal(verifyInstalled({ targetDir: target, expectedVersion: dl.version }).version, "0.8.0");
|
|
918
|
+
// 新版本自带的文件确实在(lib/index.js 里的 version 常量已是新的)
|
|
919
|
+
assert.match(fs.readFileSync(path.join(target, "lib", "index.js"), "utf8"), /0\.8\.0/);
|
|
920
|
+
});
|