@koishi-ce/plugin-market 1.1.3 → 1.1.4
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/dist/index.js +2 -2
- package/dist/style.css +1 -1
- package/package.json +4 -4
- package/src/__tests__/commands.test.ts +349 -0
- package/src/__tests__/helpers.ts +380 -0
- package/src/__tests__/installer.test.ts +255 -0
- package/src/__tests__/provider.test.ts +276 -0
- package/src/__tests__/retry.test.ts +169 -0
- package/src/index.test.ts +0 -1242
package/src/index.test.ts
DELETED
|
@@ -1,1242 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
-
// Copyright (c) 2019-present Shigma and Koishijs contributors.
|
|
3
|
-
// Copyright (c) 2026-present Koishi-CE contributors.
|
|
4
|
-
|
|
5
|
-
import {
|
|
6
|
-
afterAll,
|
|
7
|
-
beforeAll,
|
|
8
|
-
describe,
|
|
9
|
-
expect,
|
|
10
|
-
it,
|
|
11
|
-
mock,
|
|
12
|
-
} from "bun:test";
|
|
13
|
-
import {
|
|
14
|
-
mkdirSync,
|
|
15
|
-
mkdtempSync,
|
|
16
|
-
rmSync,
|
|
17
|
-
writeFileSync,
|
|
18
|
-
} from "node:fs";
|
|
19
|
-
import net from "node:net";
|
|
20
|
-
import { tmpdir } from "node:os";
|
|
21
|
-
import { join } from "node:path";
|
|
22
|
-
import memory from "@koishi-ce/plugin-database-memory";
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* market 插件测试:
|
|
26
|
-
* - 子进程(bun 安装)经 mock.module 拦截 installer/proc.ts(spawnBun 封装),不落盘不联网安装;
|
|
27
|
-
* - registry 网络请求由进程内 Bun.serve 提供(registry 协议的最小 JSON);
|
|
28
|
-
* - 宿主环境(loader / cwd)使用 FakeLoader 与临时目录 + chdir,
|
|
29
|
-
* Installer 的 override 写盘只会作用于临时 package.json。
|
|
30
|
-
*/
|
|
31
|
-
|
|
32
|
-
/** 已发生的子进程调用(参数列表)。 */
|
|
33
|
-
const spawnCalls: string[][] = [];
|
|
34
|
-
/** 控制下一次子进程的退出码与触发事件。 */
|
|
35
|
-
let nextExitCode = 0;
|
|
36
|
-
let nextSpawnError = false;
|
|
37
|
-
|
|
38
|
-
const spawnBunMock = (_args: string[], _cwd: string) => {
|
|
39
|
-
spawnCalls.push(_args);
|
|
40
|
-
return {
|
|
41
|
-
on(event: string, cb: (code?: number) => void) {
|
|
42
|
-
if (nextSpawnError) {
|
|
43
|
-
if (event === "error") setImmediate(() => cb());
|
|
44
|
-
} else if (event === "exit") {
|
|
45
|
-
setImmediate(() => cb(nextExitCode));
|
|
46
|
-
}
|
|
47
|
-
return this;
|
|
48
|
-
},
|
|
49
|
-
stderr: {
|
|
50
|
-
on(event: string, cb: (data: Buffer) => void) {
|
|
51
|
-
// 同步派发:异步派发会晚于用例的日志静默窗口,导致转发告警漏出刷屏
|
|
52
|
-
if (event === "data")
|
|
53
|
-
cb(Buffer.from("stderr line\n"));
|
|
54
|
-
return this;
|
|
55
|
-
},
|
|
56
|
-
},
|
|
57
|
-
stdout: {
|
|
58
|
-
on(event: string, cb: (data: Buffer) => void) {
|
|
59
|
-
if (event === "data")
|
|
60
|
-
cb(Buffer.from("stdout line\n"));
|
|
61
|
-
return this;
|
|
62
|
-
},
|
|
63
|
-
},
|
|
64
|
-
};
|
|
65
|
-
};
|
|
66
|
-
|
|
67
|
-
mock.module("./node/installer/proc.ts", () => ({
|
|
68
|
-
spawnBun: spawnBunMock,
|
|
69
|
-
}));
|
|
70
|
-
|
|
71
|
-
import type { Entry } from "@koishi-ce/console";
|
|
72
|
-
// 均为 type-only 导入:编译期擦除,不干扰 mock.module 先于插件加载的时序
|
|
73
|
-
import type { Plugin } from "@koishi-ce/koishi";
|
|
74
|
-
import type { RemotePackage } from "@koishi-ce/registry";
|
|
75
|
-
|
|
76
|
-
const { Console } = await import("@koishi-ce/console");
|
|
77
|
-
const { App, Logger, Service } = await import(
|
|
78
|
-
"@koishi-ce/koishi"
|
|
79
|
-
);
|
|
80
|
-
const http = (await import("@koishi-ce/plugin-http"))
|
|
81
|
-
.default;
|
|
82
|
-
const { isResidentInCache } = await import(
|
|
83
|
-
"@koishi-ce/registry"
|
|
84
|
-
);
|
|
85
|
-
const market = await import("./node/index.ts");
|
|
86
|
-
const { default: Installer } = await import(
|
|
87
|
-
"./node/installer/index.ts"
|
|
88
|
-
);
|
|
89
|
-
const mockPlugin = (await import("@koishi-ce/plugin-mock"))
|
|
90
|
-
.default;
|
|
91
|
-
// 加载包入口占位文件(纯 re-export,无独立逻辑),保证 src 全量被加载
|
|
92
|
-
await import("./index.ts");
|
|
93
|
-
|
|
94
|
-
/** 控制台服务桩:仅实现入口登记所需的最小面。 */
|
|
95
|
-
class FakeConsole extends Console {
|
|
96
|
-
protected resolveEntry(
|
|
97
|
-
_files: Entry.Files,
|
|
98
|
-
_key: string,
|
|
99
|
-
): string[] {
|
|
100
|
-
return [];
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/** loader 服务桩(immediate Service):writable 可写、envData 供重启消息断言。 */
|
|
105
|
-
class FakeLoader extends Service {
|
|
106
|
-
writable = true;
|
|
107
|
-
envData: Record<string, unknown> = { message: null };
|
|
108
|
-
paths() {
|
|
109
|
-
return ["group:entry", "plugins"];
|
|
110
|
-
}
|
|
111
|
-
fullReload() {}
|
|
112
|
-
constructor(
|
|
113
|
-
ctx: ConstructorParameters<typeof Service>[0],
|
|
114
|
-
) {
|
|
115
|
-
super(ctx, "loader", true);
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/** registry 协议最小 JSON 服务(Bun.serve,随机端口)。 */
|
|
120
|
-
const registryData: {
|
|
121
|
-
[key: string]: {
|
|
122
|
-
versions: Record<
|
|
123
|
-
string,
|
|
124
|
-
{
|
|
125
|
-
version: string;
|
|
126
|
-
peerDependencies?: Record<string, string>;
|
|
127
|
-
deprecated?: boolean;
|
|
128
|
-
}
|
|
129
|
-
>;
|
|
130
|
-
time?: Record<string, string>;
|
|
131
|
-
};
|
|
132
|
-
} = {};
|
|
133
|
-
|
|
134
|
-
/** 搜索接口响应(/-/v1/search,collect 阶段消费)。初始给空结果,避免启动期空转 404。 */
|
|
135
|
-
let searchResponse: {
|
|
136
|
-
objects: unknown[];
|
|
137
|
-
total: number;
|
|
138
|
-
} | null = {
|
|
139
|
-
objects: [],
|
|
140
|
-
total: 0,
|
|
141
|
-
};
|
|
142
|
-
|
|
143
|
-
/** flaky-index 镜像索引桩:已发生的请求计数与计划内的 404 次数。 */
|
|
144
|
-
let flakyIndexHits = 0;
|
|
145
|
-
let flakyIndexMisses = 0;
|
|
146
|
-
/** slow-index 慢响应桩:已发生的请求计数。 */
|
|
147
|
-
let slowIndexHits = 0;
|
|
148
|
-
|
|
149
|
-
/** 声明预期触发 market 域告警(404 / 限流等桩失败路径)的用例:执行期间静默该域。 */
|
|
150
|
-
const itQuiet = (
|
|
151
|
-
name: string,
|
|
152
|
-
fn: () => Promise<void> | void,
|
|
153
|
-
timeout?: number,
|
|
154
|
-
) =>
|
|
155
|
-
it(
|
|
156
|
-
name,
|
|
157
|
-
async () => {
|
|
158
|
-
const levels = Logger.levels as Record<
|
|
159
|
-
string,
|
|
160
|
-
number
|
|
161
|
-
>;
|
|
162
|
-
levels["market"] = 0;
|
|
163
|
-
try {
|
|
164
|
-
await fn();
|
|
165
|
-
} finally {
|
|
166
|
-
delete levels["market"];
|
|
167
|
-
}
|
|
168
|
-
},
|
|
169
|
-
timeout,
|
|
170
|
-
);
|
|
171
|
-
|
|
172
|
-
const registryServer = Bun.serve({
|
|
173
|
-
port: 0,
|
|
174
|
-
hostname: "127.0.0.1",
|
|
175
|
-
async fetch(request) {
|
|
176
|
-
const url = new URL(request.url);
|
|
177
|
-
if (url.pathname.startsWith("/-/v1/search")) {
|
|
178
|
-
if (!searchResponse) {
|
|
179
|
-
return new Response("not found", { status: 404 });
|
|
180
|
-
}
|
|
181
|
-
return Response.json(searchResponse);
|
|
182
|
-
}
|
|
183
|
-
// 模拟镜像索引的部署窗口:前 flakyIndexMisses 次 404,随后返回完整索引
|
|
184
|
-
if (url.pathname === "/flaky-index") {
|
|
185
|
-
if (flakyIndexHits++ < flakyIndexMisses) {
|
|
186
|
-
return new Response("not found", { status: 404 });
|
|
187
|
-
}
|
|
188
|
-
return Response.json({
|
|
189
|
-
version: "4",
|
|
190
|
-
objects: [
|
|
191
|
-
{
|
|
192
|
-
package: {
|
|
193
|
-
name: "koishi-plugin-demo",
|
|
194
|
-
version: "2.0.0",
|
|
195
|
-
date: "2024-06-01T00:00:00Z",
|
|
196
|
-
keywords: ["koishi", "plugin"],
|
|
197
|
-
},
|
|
198
|
-
},
|
|
199
|
-
],
|
|
200
|
-
});
|
|
201
|
-
}
|
|
202
|
-
// 模拟镜像索引挂起:延迟超过用例配置的 timeout,驱动超时重试路径
|
|
203
|
-
if (url.pathname === "/slow-index") {
|
|
204
|
-
slowIndexHits++;
|
|
205
|
-
await new Promise((resolve) =>
|
|
206
|
-
setTimeout(resolve, 300),
|
|
207
|
-
);
|
|
208
|
-
return Response.json({ version: "4", objects: [] });
|
|
209
|
-
}
|
|
210
|
-
// 模拟 registry 限流:恒定 429 + 极短的 Retry-After,驱动重试后失败
|
|
211
|
-
if (
|
|
212
|
-
url.pathname.includes("koishi-plugin-ratelimited")
|
|
213
|
-
) {
|
|
214
|
-
return new Response("rate limited", {
|
|
215
|
-
status: 429,
|
|
216
|
-
headers: { "Retry-After": "0.01" },
|
|
217
|
-
});
|
|
218
|
-
}
|
|
219
|
-
const name = decodeURIComponent(url.pathname.slice(1));
|
|
220
|
-
const data = registryData[name];
|
|
221
|
-
if (!data) {
|
|
222
|
-
return new Response("not found", { status: 404 });
|
|
223
|
-
}
|
|
224
|
-
return Response.json(data);
|
|
225
|
-
},
|
|
226
|
-
});
|
|
227
|
-
|
|
228
|
-
// 预置一个兼容插件包:最新版 2.0.0,旧版 1.0.0 均声明 koishi ^4 peer
|
|
229
|
-
registryData["koishi-plugin-demo"] = {
|
|
230
|
-
versions: {
|
|
231
|
-
"1.0.0": {
|
|
232
|
-
version: "1.0.0",
|
|
233
|
-
peerDependencies: { koishi: "^4.17.0" },
|
|
234
|
-
},
|
|
235
|
-
"2.0.0": {
|
|
236
|
-
version: "2.0.0",
|
|
237
|
-
peerDependencies: { koishi: "^4.17.0" },
|
|
238
|
-
},
|
|
239
|
-
},
|
|
240
|
-
time: {
|
|
241
|
-
"1.0.0": "2024-01-01T00:00:00Z",
|
|
242
|
-
"2.0.0": "2024-06-01T00:00:00Z",
|
|
243
|
-
},
|
|
244
|
-
};
|
|
245
|
-
// 预置一个未安装的插件包(plugin.install 安装路径使用)
|
|
246
|
-
registryData["koishi-plugin-newpkg"] = {
|
|
247
|
-
versions: {
|
|
248
|
-
"1.0.0": {
|
|
249
|
-
version: "1.0.0",
|
|
250
|
-
peerDependencies: { koishi: "^4.17.0" },
|
|
251
|
-
},
|
|
252
|
-
},
|
|
253
|
-
time: { "1.0.0": "2024-01-01T00:00:00Z" },
|
|
254
|
-
};
|
|
255
|
-
// 预置初始清单里三个非插件依赖的远端条目:Installer.refresh() 每轮都会对全部
|
|
256
|
-
// 清单依赖发起 registry 查询,缺桩会让每个用例都刷 404 告警
|
|
257
|
-
registryData["koishi"] = {
|
|
258
|
-
versions: { "4.18.0": { version: "4.18.0" } },
|
|
259
|
-
time: { "4.18.0": "2024-01-01T00:00:00Z" },
|
|
260
|
-
};
|
|
261
|
-
registryData["market-alias"] = {
|
|
262
|
-
versions: { "1.0.0": { version: "1.0.0" } },
|
|
263
|
-
time: { "1.0.0": "2024-01-01T00:00:00Z" },
|
|
264
|
-
};
|
|
265
|
-
registryData["bad-range"] = {
|
|
266
|
-
versions: { "1.0.0": { version: "1.0.0" } },
|
|
267
|
-
time: { "1.0.0": "2024-01-01T00:00:00Z" },
|
|
268
|
-
};
|
|
269
|
-
|
|
270
|
-
/** 临时宿主目录(Installer 的 cwd 与 override 写盘目标)。 */
|
|
271
|
-
const initialDependencies = {
|
|
272
|
-
// 护栏:workspace 声明不可被覆盖或删除
|
|
273
|
-
koishi: "workspace:*",
|
|
274
|
-
// 护栏:npm:@koishi-ce alias 同样受保护
|
|
275
|
-
"market-alias": "npm:@koishi-ce/anything@^1.0.0",
|
|
276
|
-
// 普通依赖:可解析远端最新版
|
|
277
|
-
"koishi-plugin-demo": "^1.0.0",
|
|
278
|
-
// 非法 semver 区间:应标记 invalid
|
|
279
|
-
"bad-range": "not-a-version",
|
|
280
|
-
};
|
|
281
|
-
const tmp = mkdtempSync(join(tmpdir(), "market-test-"));
|
|
282
|
-
const originalCwd = process.cwd();
|
|
283
|
-
writeFileSync(
|
|
284
|
-
join(tmp, "package.json"),
|
|
285
|
-
JSON.stringify(
|
|
286
|
-
{
|
|
287
|
-
name: "market-host",
|
|
288
|
-
dependencies: initialDependencies,
|
|
289
|
-
},
|
|
290
|
-
null,
|
|
291
|
-
"\t",
|
|
292
|
-
),
|
|
293
|
-
);
|
|
294
|
-
process.chdir(tmp);
|
|
295
|
-
|
|
296
|
-
// App 经动态 import 取值为 const,实例类型由构造器派生供 Plugin.Constructor 泛型使用
|
|
297
|
-
type TestApp = InstanceType<typeof App>;
|
|
298
|
-
|
|
299
|
-
const app = new App();
|
|
300
|
-
|
|
301
|
-
app.plugin(memory);
|
|
302
|
-
app.plugin(http);
|
|
303
|
-
// Console 基类的 static inject 是 cordis 3 旧形态,与 Plugin.Constructor 期待类型不兼容,仅做类型层转型
|
|
304
|
-
app.plugin(
|
|
305
|
-
FakeConsole as unknown as Plugin.Constructor<TestApp>,
|
|
306
|
-
);
|
|
307
|
-
app.plugin(FakeLoader);
|
|
308
|
-
app.plugin(market, {
|
|
309
|
-
registry: {
|
|
310
|
-
endpoint: `http://127.0.0.1:${registryServer.port}/`,
|
|
311
|
-
},
|
|
312
|
-
});
|
|
313
|
-
app.plugin(mockPlugin);
|
|
314
|
-
|
|
315
|
-
const client = app.mock.client("123");
|
|
316
|
-
|
|
317
|
-
beforeAll(async () => {
|
|
318
|
-
await app.start();
|
|
319
|
-
await app.mock.initUser("123", 4);
|
|
320
|
-
// 触发 installer 等延迟服务的实例化
|
|
321
|
-
expect(app.installer).toBeDefined();
|
|
322
|
-
});
|
|
323
|
-
|
|
324
|
-
afterAll(async () => {
|
|
325
|
-
await app.stop();
|
|
326
|
-
process.chdir(originalCwd);
|
|
327
|
-
rmSync(tmp, { recursive: true, force: true });
|
|
328
|
-
registryServer.stop(true);
|
|
329
|
-
});
|
|
330
|
-
|
|
331
|
-
describe("market 插件", () => {
|
|
332
|
-
it("注册三个数据服务与浏览器监听器", () => {
|
|
333
|
-
expect(
|
|
334
|
-
app.get("console.services.market"),
|
|
335
|
-
).toBeDefined();
|
|
336
|
-
expect(
|
|
337
|
-
app.get("console.services.dependencies"),
|
|
338
|
-
).toBeDefined();
|
|
339
|
-
expect(
|
|
340
|
-
app.get("console.services.registry"),
|
|
341
|
-
).toBeDefined();
|
|
342
|
-
expect(
|
|
343
|
-
app.console.listeners["market/install"],
|
|
344
|
-
).toBeDefined();
|
|
345
|
-
expect(
|
|
346
|
-
app.console.listeners["market/registry"],
|
|
347
|
-
).toBeDefined();
|
|
348
|
-
});
|
|
349
|
-
|
|
350
|
-
it("resolveName 解析插件短名的候选全名", () => {
|
|
351
|
-
const installer = app.installer;
|
|
352
|
-
expect(
|
|
353
|
-
installer.resolveName("@koishijs/plugin-echo"),
|
|
354
|
-
).toEqual(["@koishijs/plugin-echo"]);
|
|
355
|
-
expect(
|
|
356
|
-
installer.resolveName("koishi-plugin-echo"),
|
|
357
|
-
).toEqual(["koishi-plugin-echo"]);
|
|
358
|
-
expect(installer.resolveName("@scope/echo")).toEqual([
|
|
359
|
-
"@scope/koishi-plugin-echo",
|
|
360
|
-
]);
|
|
361
|
-
expect(installer.resolveName("echo")).toEqual([
|
|
362
|
-
"@koishijs/plugin-echo",
|
|
363
|
-
"koishi-plugin-echo",
|
|
364
|
-
]);
|
|
365
|
-
});
|
|
366
|
-
|
|
367
|
-
it("getDeps 汇总本地依赖并带出远端最新版", async () => {
|
|
368
|
-
const deps = await app.installer.getDeps();
|
|
369
|
-
// 语义化区间去除前缀符号
|
|
370
|
-
expect(deps["koishi-plugin-demo"]?.request).toBe(
|
|
371
|
-
"1.0.0",
|
|
372
|
-
);
|
|
373
|
-
// 远端最新版(本地 registry 预置 2.0.0)
|
|
374
|
-
expect(deps["koishi-plugin-demo"]?.latest).toBe(
|
|
375
|
-
"2.0.0",
|
|
376
|
-
);
|
|
377
|
-
// 非法 semver 标记 invalid
|
|
378
|
-
expect(deps["bad-range"]?.invalid).toBe(true);
|
|
379
|
-
});
|
|
380
|
-
|
|
381
|
-
itQuiet(
|
|
382
|
-
"findVersion 返回首个存在的候选包版本",
|
|
383
|
-
async () => {
|
|
384
|
-
const found = await app.installer.findVersion([
|
|
385
|
-
"@koishijs/plugin-none",
|
|
386
|
-
"koishi-plugin-demo",
|
|
387
|
-
]);
|
|
388
|
-
expect(found).toEqual({
|
|
389
|
-
"koishi-plugin-demo": "2.0.0",
|
|
390
|
-
});
|
|
391
|
-
// 全部不存在时返回 undefined
|
|
392
|
-
expect(
|
|
393
|
-
await app.installer.findVersion([
|
|
394
|
-
"@koishijs/plugin-none",
|
|
395
|
-
]),
|
|
396
|
-
).toBeUndefined();
|
|
397
|
-
},
|
|
398
|
-
);
|
|
399
|
-
|
|
400
|
-
itQuiet("getPackage 拉取失败时回退为空表", async () => {
|
|
401
|
-
const versions = await app.installer.getPackage(
|
|
402
|
-
"koishi-plugin-missing",
|
|
403
|
-
);
|
|
404
|
-
expect(versions).toEqual({});
|
|
405
|
-
});
|
|
406
|
-
|
|
407
|
-
it("setPackage 写入缓存并触发节流广播", async () => {
|
|
408
|
-
app.installer.setPackage("koishi-plugin-demo", [
|
|
409
|
-
{
|
|
410
|
-
version: "3.0.0",
|
|
411
|
-
peerDependencies: { koishi: "^4.17.0" },
|
|
412
|
-
// RemotePackage 的其余元数据字段与本断言无关,最小载荷经 unknown 二段式断言
|
|
413
|
-
} as unknown as RemotePackage,
|
|
414
|
-
]);
|
|
415
|
-
expect(
|
|
416
|
-
Object.keys(
|
|
417
|
-
app.installer.fullCache["koishi-plugin-demo"] ?? {},
|
|
418
|
-
),
|
|
419
|
-
).toEqual(["3.0.0"]);
|
|
420
|
-
// 等待节流窗口
|
|
421
|
-
await new Promise((resolve) =>
|
|
422
|
-
setTimeout(resolve, 600),
|
|
423
|
-
);
|
|
424
|
-
});
|
|
425
|
-
});
|
|
426
|
-
|
|
427
|
-
describe("Installer 安装链路", () => {
|
|
428
|
-
itQuiet(
|
|
429
|
-
"install 尊重护栏依赖并执行子进程安装",
|
|
430
|
-
async () => {
|
|
431
|
-
nextExitCode = 0;
|
|
432
|
-
spawnCalls.length = 0;
|
|
433
|
-
const code = await app.installer.install({
|
|
434
|
-
koishi: "2.0.0",
|
|
435
|
-
"market-alias": null,
|
|
436
|
-
"koishi-plugin-demo": "^1.0.0",
|
|
437
|
-
});
|
|
438
|
-
expect(code).toBe(0);
|
|
439
|
-
// 触发了包管理器安装(bun install --registry …)
|
|
440
|
-
expect(spawnCalls.length).toBe(1);
|
|
441
|
-
expect(spawnCalls[0]?.[0]).toBe("install");
|
|
442
|
-
// 重新读取临时 package.json:护栏项保持原样,新依赖加入
|
|
443
|
-
const manifest = JSON.parse(
|
|
444
|
-
await Bun.file(join(tmp, "package.json")).text(),
|
|
445
|
-
) as { dependencies: Record<string, string> };
|
|
446
|
-
expect(manifest.dependencies["koishi"]).toBe(
|
|
447
|
-
"workspace:*",
|
|
448
|
-
);
|
|
449
|
-
expect(manifest.dependencies["market-alias"]).toBe(
|
|
450
|
-
"npm:@koishi-ce/anything@^1.0.0",
|
|
451
|
-
);
|
|
452
|
-
expect(
|
|
453
|
-
manifest.dependencies["koishi-plugin-demo"],
|
|
454
|
-
).toBe("^1.0.0");
|
|
455
|
-
},
|
|
456
|
-
15000,
|
|
457
|
-
);
|
|
458
|
-
|
|
459
|
-
itQuiet(
|
|
460
|
-
"install 强制时无视本地满足也要装",
|
|
461
|
-
async () => {
|
|
462
|
-
nextExitCode = 0;
|
|
463
|
-
spawnCalls.length = 0;
|
|
464
|
-
const code = await app.installer.install(
|
|
465
|
-
{ "koishi-plugin-demo": "^1.0.0" },
|
|
466
|
-
true,
|
|
467
|
-
);
|
|
468
|
-
expect(code).toBe(0);
|
|
469
|
-
expect(spawnCalls.length).toBe(1);
|
|
470
|
-
},
|
|
471
|
-
15000,
|
|
472
|
-
);
|
|
473
|
-
|
|
474
|
-
itQuiet(
|
|
475
|
-
"子进程非零退出码向上传递",
|
|
476
|
-
async () => {
|
|
477
|
-
nextExitCode = 1;
|
|
478
|
-
spawnCalls.length = 0;
|
|
479
|
-
const code = await app.installer.install(
|
|
480
|
-
{ "koishi-plugin-demo": "^2.0.0" },
|
|
481
|
-
true,
|
|
482
|
-
);
|
|
483
|
-
expect(code).toBe(1);
|
|
484
|
-
},
|
|
485
|
-
15000,
|
|
486
|
-
);
|
|
487
|
-
|
|
488
|
-
itQuiet("子进程 spawn 失败返回 -1", async () => {
|
|
489
|
-
nextSpawnError = true;
|
|
490
|
-
nextExitCode = 0;
|
|
491
|
-
const code = await app.installer.exec(["install"]);
|
|
492
|
-
nextSpawnError = false;
|
|
493
|
-
expect(code).toBe(-1);
|
|
494
|
-
});
|
|
495
|
-
|
|
496
|
-
/** 在临时 node_modules 落盘一个带 dependencies 声明的校验用包 */
|
|
497
|
-
function placeLinkcheckPkg(
|
|
498
|
-
dependencies: Record<string, string>,
|
|
499
|
-
) {
|
|
500
|
-
const name = "koishi-plugin-linkcheck";
|
|
501
|
-
const pkgDir = join(tmp, "node_modules", name);
|
|
502
|
-
mkdirSync(pkgDir, { recursive: true });
|
|
503
|
-
writeFileSync(
|
|
504
|
-
join(pkgDir, "package.json"),
|
|
505
|
-
JSON.stringify({
|
|
506
|
-
name,
|
|
507
|
-
version: "1.0.0",
|
|
508
|
-
dependencies,
|
|
509
|
-
}),
|
|
510
|
-
);
|
|
511
|
-
return pkgDir;
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
itQuiet(
|
|
515
|
-
"装后依赖链接完整时不触发补装",
|
|
516
|
-
async () => {
|
|
517
|
-
nextExitCode = 0;
|
|
518
|
-
spawnCalls.length = 0;
|
|
519
|
-
const pkgDir = placeLinkcheckPkg({
|
|
520
|
-
"linkcheck-dep": "^1.0.0",
|
|
521
|
-
});
|
|
522
|
-
const depDir = join(
|
|
523
|
-
tmp,
|
|
524
|
-
"node_modules",
|
|
525
|
-
"linkcheck-dep",
|
|
526
|
-
);
|
|
527
|
-
mkdirSync(depDir, { recursive: true });
|
|
528
|
-
writeFileSync(
|
|
529
|
-
join(depDir, "package.json"),
|
|
530
|
-
JSON.stringify({
|
|
531
|
-
name: "linkcheck-dep",
|
|
532
|
-
version: "1.0.0",
|
|
533
|
-
}),
|
|
534
|
-
);
|
|
535
|
-
try {
|
|
536
|
-
const code = await app.installer.install(
|
|
537
|
-
{ "koishi-plugin-linkcheck": "^1.0.0" },
|
|
538
|
-
true,
|
|
539
|
-
);
|
|
540
|
-
expect(code).toBe(0);
|
|
541
|
-
expect(spawnCalls.length).toBe(1);
|
|
542
|
-
} finally {
|
|
543
|
-
rmSync(pkgDir, { recursive: true, force: true });
|
|
544
|
-
rmSync(depDir, { recursive: true, force: true });
|
|
545
|
-
}
|
|
546
|
-
},
|
|
547
|
-
15000,
|
|
548
|
-
);
|
|
549
|
-
|
|
550
|
-
itQuiet(
|
|
551
|
-
"包级依赖链接缺失时自动补装一次且不无限重试",
|
|
552
|
-
async () => {
|
|
553
|
-
nextExitCode = 0;
|
|
554
|
-
spawnCalls.length = 0;
|
|
555
|
-
// isolated 增量安装缺陷形态:包已落盘但声明的依赖未链接。
|
|
556
|
-
// 首装后校验命中补装一次;mock 安装不会真落盘依赖,二次校验
|
|
557
|
-
// 仍缺,只告警不再补装——总计恰好两次子进程调用
|
|
558
|
-
const pkgDir = placeLinkcheckPkg({
|
|
559
|
-
"linkcheck-missing": "^1.0.0",
|
|
560
|
-
});
|
|
561
|
-
try {
|
|
562
|
-
const code = await app.installer.install(
|
|
563
|
-
{ "koishi-plugin-linkcheck": "^1.0.0" },
|
|
564
|
-
true,
|
|
565
|
-
);
|
|
566
|
-
expect(code).toBe(0);
|
|
567
|
-
expect(spawnCalls.length).toBe(2);
|
|
568
|
-
} finally {
|
|
569
|
-
rmSync(pkgDir, { recursive: true, force: true });
|
|
570
|
-
}
|
|
571
|
-
},
|
|
572
|
-
15000,
|
|
573
|
-
);
|
|
574
|
-
|
|
575
|
-
itQuiet(
|
|
576
|
-
"alias 声明的依赖跳过链接校验",
|
|
577
|
-
async () => {
|
|
578
|
-
nextExitCode = 0;
|
|
579
|
-
spawnCalls.length = 0;
|
|
580
|
-
// alias 落盘名与键名无直接映射,不入校验,缺失也不触发补装
|
|
581
|
-
const pkgDir = placeLinkcheckPkg({
|
|
582
|
-
"linkcheck-alias": "npm:whatever@^1.0.0",
|
|
583
|
-
});
|
|
584
|
-
try {
|
|
585
|
-
const code = await app.installer.install(
|
|
586
|
-
{ "koishi-plugin-linkcheck": "^1.0.0" },
|
|
587
|
-
true,
|
|
588
|
-
);
|
|
589
|
-
expect(code).toBe(0);
|
|
590
|
-
expect(spawnCalls.length).toBe(1);
|
|
591
|
-
} finally {
|
|
592
|
-
rmSync(pkgDir, { recursive: true, force: true });
|
|
593
|
-
}
|
|
594
|
-
},
|
|
595
|
-
15000,
|
|
596
|
-
);
|
|
597
|
-
|
|
598
|
-
itQuiet(
|
|
599
|
-
"exec 收集子进程 stdout / stderr 输出行",
|
|
600
|
-
async () => {
|
|
601
|
-
nextExitCode = 0;
|
|
602
|
-
nextSpawnError = false;
|
|
603
|
-
const code = await app.installer.exec(["install"]);
|
|
604
|
-
expect(code).toBe(0);
|
|
605
|
-
},
|
|
606
|
-
);
|
|
607
|
-
});
|
|
608
|
-
|
|
609
|
-
describe("isResidentInCache(装后重载判定)", () => {
|
|
610
|
-
/** 在临时 node_modules 放一个已安装包,返回其目录 */
|
|
611
|
-
function placePkg(name: string) {
|
|
612
|
-
const pkgDir = join(tmp, "node_modules", name);
|
|
613
|
-
mkdirSync(pkgDir, { recursive: true });
|
|
614
|
-
writeFileSync(
|
|
615
|
-
join(pkgDir, "package.json"),
|
|
616
|
-
JSON.stringify({
|
|
617
|
-
name,
|
|
618
|
-
version: "1.0.0",
|
|
619
|
-
main: "index.js",
|
|
620
|
-
}),
|
|
621
|
-
);
|
|
622
|
-
writeFileSync(
|
|
623
|
-
join(pkgDir, "index.js"),
|
|
624
|
-
"module.exports = {}",
|
|
625
|
-
);
|
|
626
|
-
return pkgDir;
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
it("包目录下有模块驻留 require.cache 时返回 true", () => {
|
|
630
|
-
const pkgDir = placePkg("koishi-plugin-resident-check");
|
|
631
|
-
// 入口经 require 进入 require.cache,模拟旧版本驻留内存
|
|
632
|
-
require(join(pkgDir, "index.js"));
|
|
633
|
-
expect(
|
|
634
|
-
isResidentInCache("koishi-plugin-resident-check"),
|
|
635
|
-
).toBe(true);
|
|
636
|
-
});
|
|
637
|
-
|
|
638
|
-
it("已安装但无模块驻留内存时返回 false", () => {
|
|
639
|
-
placePkg("koishi-plugin-idle-check");
|
|
640
|
-
expect(
|
|
641
|
-
isResidentInCache("koishi-plugin-idle-check"),
|
|
642
|
-
).toBe(false);
|
|
643
|
-
});
|
|
644
|
-
|
|
645
|
-
it("包不存在时保守返回 true(宁可多重载不漏判)", () => {
|
|
646
|
-
expect(
|
|
647
|
-
isResidentInCache("koishi-plugin-absent-check"),
|
|
648
|
-
).toBe(true);
|
|
649
|
-
});
|
|
650
|
-
});
|
|
651
|
-
|
|
652
|
-
describe("registry 配置探测", () => {
|
|
653
|
-
it("无显式 endpoint 时按 npmrc / 环境变量探测", async () => {
|
|
654
|
-
// 环境变量优先:npm_config_registry 指向本地服务
|
|
655
|
-
process.env["npm_config_registry"] =
|
|
656
|
-
`http://127.0.0.1:${registryServer.port}/`;
|
|
657
|
-
const app2 = new App();
|
|
658
|
-
app2.plugin(http);
|
|
659
|
-
app2.plugin(Installer, {});
|
|
660
|
-
await app2.start();
|
|
661
|
-
expect(app2.installer.endpoint).toBe(
|
|
662
|
-
`http://127.0.0.1:${registryServer.port}/`,
|
|
663
|
-
);
|
|
664
|
-
await app2.stop();
|
|
665
|
-
|
|
666
|
-
// 环境变量缺失时回落到项目 .npmrc(含不合法行与合法 registry 行)
|
|
667
|
-
delete process.env["npm_config_registry"];
|
|
668
|
-
writeFileSync(
|
|
669
|
-
join(tmp, ".npmrc"),
|
|
670
|
-
"not-a-registry-line\nregistry=http://registry.example.npm/\n",
|
|
671
|
-
);
|
|
672
|
-
const app3 = new App();
|
|
673
|
-
app3.plugin(http);
|
|
674
|
-
app3.plugin(Installer, {});
|
|
675
|
-
await app3.start();
|
|
676
|
-
expect(app3.installer.endpoint).toBe(
|
|
677
|
-
"http://registry.example.npm/",
|
|
678
|
-
);
|
|
679
|
-
await app3.stop();
|
|
680
|
-
rmSync(join(tmp, ".npmrc"), { force: true });
|
|
681
|
-
});
|
|
682
|
-
});
|
|
683
|
-
|
|
684
|
-
describe("MarketProvider 市场数据服务", () => {
|
|
685
|
-
it("collect 经搜索接口收集并逐包分析填充缓存", async () => {
|
|
686
|
-
const svc = app.get("console.services.market");
|
|
687
|
-
expect(svc).toBeDefined();
|
|
688
|
-
// 提供搜索结果:一个插件条目 + 一个被忽略条目
|
|
689
|
-
searchResponse = {
|
|
690
|
-
objects: [
|
|
691
|
-
{
|
|
692
|
-
package: {
|
|
693
|
-
name: "koishi-plugin-demo",
|
|
694
|
-
version: "1.0.0",
|
|
695
|
-
date: "2024-01-01T00:00:00Z",
|
|
696
|
-
keywords: ["koishi", "plugin", "Tool"],
|
|
697
|
-
},
|
|
698
|
-
},
|
|
699
|
-
{
|
|
700
|
-
package: {
|
|
701
|
-
name: "not-a-plugin",
|
|
702
|
-
date: "2024-01-01T00:00:00Z",
|
|
703
|
-
},
|
|
704
|
-
},
|
|
705
|
-
],
|
|
706
|
-
total: 1,
|
|
707
|
-
};
|
|
708
|
-
// start(true) 强制刷新市场数据(重新 collect)
|
|
709
|
-
await svc?.start(true);
|
|
710
|
-
// 等待节流窗口与逐包分析完成
|
|
711
|
-
await new Promise((resolve) =>
|
|
712
|
-
setTimeout(resolve, 700),
|
|
713
|
-
);
|
|
714
|
-
const payload = await svc?.get();
|
|
715
|
-
expect(payload).toBeDefined();
|
|
716
|
-
// 非 plugin 条目被剔除,只保留 demo
|
|
717
|
-
expect(Object.keys(payload?.data ?? {})).toEqual([
|
|
718
|
-
"koishi-plugin-demo",
|
|
719
|
-
]);
|
|
720
|
-
expect(payload?.total).toBe(1);
|
|
721
|
-
expect(payload?.failed).toBe(0);
|
|
722
|
-
expect(payload?.registry).toBe(
|
|
723
|
-
`http://127.0.0.1:${registryServer.port}/`,
|
|
724
|
-
);
|
|
725
|
-
});
|
|
726
|
-
|
|
727
|
-
it("依赖 / 注册表数据服务读取安装器缓存", async () => {
|
|
728
|
-
const dependencies = await app
|
|
729
|
-
.get("console.services.dependencies")
|
|
730
|
-
?.get();
|
|
731
|
-
expect(
|
|
732
|
-
dependencies?.["koishi-plugin-demo"]?.request,
|
|
733
|
-
).toBeTruthy();
|
|
734
|
-
expect(
|
|
735
|
-
dependencies?.["koishi-plugin-demo"]?.latest,
|
|
736
|
-
).toBe("2.0.0");
|
|
737
|
-
|
|
738
|
-
const registry = await app
|
|
739
|
-
.get("console.services.registry")
|
|
740
|
-
?.get();
|
|
741
|
-
expect(
|
|
742
|
-
Object.keys(registry?.["koishi-plugin-demo"] ?? {}),
|
|
743
|
-
).toContain("2.0.0");
|
|
744
|
-
});
|
|
745
|
-
|
|
746
|
-
itQuiet(
|
|
747
|
-
"搜索接口失败时 get 返回空数据与错误标记",
|
|
748
|
-
async () => {
|
|
749
|
-
const svc = app.get("console.services.market");
|
|
750
|
-
searchResponse = null;
|
|
751
|
-
// 强制重扫:collect 失败置 _error,get 返回空 payload
|
|
752
|
-
await svc?.start(true);
|
|
753
|
-
const payload = await svc?.get();
|
|
754
|
-
expect(payload).toEqual({
|
|
755
|
-
data: {},
|
|
756
|
-
failed: 0,
|
|
757
|
-
total: 0,
|
|
758
|
-
progress: 0,
|
|
759
|
-
});
|
|
760
|
-
searchResponse = {
|
|
761
|
-
objects: [],
|
|
762
|
-
total: 0,
|
|
763
|
-
};
|
|
764
|
-
},
|
|
765
|
-
);
|
|
766
|
-
|
|
767
|
-
it("控制台连接事件在数据过期时触发刷新", async () => {
|
|
768
|
-
const svc = app.get("console.services.market");
|
|
769
|
-
expect(svc).toBeDefined();
|
|
770
|
-
// 伪造一个在线客户端,使连接事件通过在线检查(broadcast 需可用的 socket)
|
|
771
|
-
const fakeClient = {
|
|
772
|
-
id: "conn-1",
|
|
773
|
-
socket: { send() {} },
|
|
774
|
-
};
|
|
775
|
-
(app.console.clients as Record<string, unknown>)[
|
|
776
|
-
"conn-1"
|
|
777
|
-
] = fakeClient;
|
|
778
|
-
// 刚刷新过:12 小时窗口内直接返回,不重新收集
|
|
779
|
-
const timestamp = svc?.[
|
|
780
|
-
"_timestamp" as keyof typeof svc
|
|
781
|
-
] as number;
|
|
782
|
-
// console/connection 载荷声明为 Client,桩对象仅含在线检查所需的最小面,类型层断言穿透
|
|
783
|
-
app.emit("console/connection", fakeClient as never);
|
|
784
|
-
expect(
|
|
785
|
-
svc?.["_timestamp" as keyof typeof svc] as number,
|
|
786
|
-
).toBe(timestamp);
|
|
787
|
-
// 将时间戳回拨到窗口外,连接事件重新触发 start(异步监听,稍等)
|
|
788
|
-
(svc as Record<string, unknown>)["_timestamp"] = 0;
|
|
789
|
-
app.emit("console/connection", fakeClient as never);
|
|
790
|
-
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
791
|
-
expect(
|
|
792
|
-
(svc as Record<string, unknown>)[
|
|
793
|
-
"_timestamp"
|
|
794
|
-
] as number,
|
|
795
|
-
).toBeGreaterThan(0);
|
|
796
|
-
delete app.console.clients["conn-1"];
|
|
797
|
-
});
|
|
798
|
-
});
|
|
799
|
-
|
|
800
|
-
describe("market 聊天指令", () => {
|
|
801
|
-
itQuiet(
|
|
802
|
-
"plugin.install 缺参与未找到的报错路径",
|
|
803
|
-
async () => {
|
|
804
|
-
const missing = await client.receive(
|
|
805
|
-
"plugin.install",
|
|
806
|
-
);
|
|
807
|
-
expect(missing[0]).toContain("请输入插件名。");
|
|
808
|
-
const notFound = await client.receive(
|
|
809
|
-
"plugin.install absent-pkg",
|
|
810
|
-
);
|
|
811
|
-
expect(notFound[0]).toContain("未找到该插件。");
|
|
812
|
-
},
|
|
813
|
-
);
|
|
814
|
-
|
|
815
|
-
it("plugin.install 已安装时提示重复", async () => {
|
|
816
|
-
const replies = await client.receive(
|
|
817
|
-
"plugin.install demo",
|
|
818
|
-
);
|
|
819
|
-
expect(replies[0]).toContain("该插件已安装。");
|
|
820
|
-
});
|
|
821
|
-
|
|
822
|
-
itQuiet(
|
|
823
|
-
"plugin.install 安装新插件并写入依赖",
|
|
824
|
-
async () => {
|
|
825
|
-
nextExitCode = 0;
|
|
826
|
-
spawnCalls.length = 0;
|
|
827
|
-
const replies = await client.receive(
|
|
828
|
-
"plugin.install newpkg",
|
|
829
|
-
);
|
|
830
|
-
expect(replies[0]).toContain("安装成功!");
|
|
831
|
-
expect(spawnCalls.length).toBe(1);
|
|
832
|
-
const manifest = JSON.parse(
|
|
833
|
-
await Bun.file(join(tmp, "package.json")).text(),
|
|
834
|
-
) as { dependencies: Record<string, string> };
|
|
835
|
-
expect(
|
|
836
|
-
manifest.dependencies["koishi-plugin-newpkg"],
|
|
837
|
-
).toBe("1.0.0");
|
|
838
|
-
// 重启消息在安装完成后复位(Loader 与桩形状不同,经 unknown 二段式断言)
|
|
839
|
-
expect(
|
|
840
|
-
(app.loader as unknown as FakeLoader).envData[
|
|
841
|
-
"message"
|
|
842
|
-
],
|
|
843
|
-
).toBeNull();
|
|
844
|
-
},
|
|
845
|
-
15000,
|
|
846
|
-
);
|
|
847
|
-
|
|
848
|
-
itQuiet(
|
|
849
|
-
"plugin.uninstall 卸载依赖并从清单移除",
|
|
850
|
-
async () => {
|
|
851
|
-
nextExitCode = 0;
|
|
852
|
-
spawnCalls.length = 0;
|
|
853
|
-
const replies = await client.receive(
|
|
854
|
-
"plugin.uninstall newpkg",
|
|
855
|
-
);
|
|
856
|
-
expect(replies[0]).toContain("卸载成功!");
|
|
857
|
-
const manifest = JSON.parse(
|
|
858
|
-
await Bun.file(join(tmp, "package.json")).text(),
|
|
859
|
-
) as { dependencies: Record<string, string> };
|
|
860
|
-
expect(
|
|
861
|
-
manifest.dependencies["koishi-plugin-newpkg"],
|
|
862
|
-
).toBeUndefined();
|
|
863
|
-
},
|
|
864
|
-
15000,
|
|
865
|
-
);
|
|
866
|
-
|
|
867
|
-
it("plugin.uninstall 未安装时提示", async () => {
|
|
868
|
-
const replies = await client.receive(
|
|
869
|
-
"plugin.uninstall absent-pkg",
|
|
870
|
-
);
|
|
871
|
-
expect(replies[0]).toContain("该插件未安装。");
|
|
872
|
-
});
|
|
873
|
-
|
|
874
|
-
it("plugin.upgrade 无可升级项时提示已最新", async () => {
|
|
875
|
-
const replies = await client.receive("plugin.upgrade");
|
|
876
|
-
expect(replies[0]).toContain("所有插件已是最新版本。");
|
|
877
|
-
}, 10000);
|
|
878
|
-
});
|
|
879
|
-
|
|
880
|
-
describe("market 进阶链路", () => {
|
|
881
|
-
itQuiet("宿主配置不可写时不加载安装器", async () => {
|
|
882
|
-
// apply 的「仅告警并跳过」正是被测行为,静默 app 域避免预期告警刷屏
|
|
883
|
-
const levels = Logger.levels as Record<string, number>;
|
|
884
|
-
levels["app"] = 0;
|
|
885
|
-
try {
|
|
886
|
-
const appNoLoader = new App();
|
|
887
|
-
appNoLoader.plugin(http);
|
|
888
|
-
appNoLoader.plugin(
|
|
889
|
-
FakeConsole as unknown as Plugin.Constructor<TestApp>,
|
|
890
|
-
);
|
|
891
|
-
appNoLoader.plugin(market, {
|
|
892
|
-
registry: {
|
|
893
|
-
endpoint: `http://127.0.0.1:${registryServer.port}/`,
|
|
894
|
-
},
|
|
895
|
-
});
|
|
896
|
-
await appNoLoader.start();
|
|
897
|
-
// apply 在 loader 缺席时仅告警并提前返回
|
|
898
|
-
expect(appNoLoader.installer).toBeUndefined();
|
|
899
|
-
await appNoLoader.stop();
|
|
900
|
-
} finally {
|
|
901
|
-
delete levels["app"];
|
|
902
|
-
}
|
|
903
|
-
});
|
|
904
|
-
|
|
905
|
-
itQuiet(
|
|
906
|
-
"浏览器 market/install 监听器执行安装并刷新服务",
|
|
907
|
-
async () => {
|
|
908
|
-
nextExitCode = 0;
|
|
909
|
-
const listener =
|
|
910
|
-
app.console.listeners["market/install"];
|
|
911
|
-
expect(listener).toBeDefined();
|
|
912
|
-
const code = (await listener?.callback.call(
|
|
913
|
-
{} as never,
|
|
914
|
-
{ "koishi-plugin-newpkg": "1.0.0" },
|
|
915
|
-
true,
|
|
916
|
-
)) as number;
|
|
917
|
-
expect(code).toBe(0);
|
|
918
|
-
},
|
|
919
|
-
15000,
|
|
920
|
-
);
|
|
921
|
-
|
|
922
|
-
itQuiet(
|
|
923
|
-
"浏览器 market/registry 监听器批量查询包元数据",
|
|
924
|
-
async () => {
|
|
925
|
-
const listener =
|
|
926
|
-
app.console.listeners["market/registry"];
|
|
927
|
-
expect(listener).toBeDefined();
|
|
928
|
-
const meta = (await listener?.callback.call(
|
|
929
|
-
{} as never,
|
|
930
|
-
["koishi-plugin-demo", "koishi-plugin-missing"],
|
|
931
|
-
)) as Record<string, unknown>;
|
|
932
|
-
expect(
|
|
933
|
-
Object.keys(meta["koishi-plugin-demo"] ?? {}),
|
|
934
|
-
).toContain("2.0.0");
|
|
935
|
-
expect(meta["koishi-plugin-missing"]).toEqual({});
|
|
936
|
-
},
|
|
937
|
-
);
|
|
938
|
-
|
|
939
|
-
it("搜索结果中不兼容的包被跳过(analyze onSkipped/ignored)", async () => {
|
|
940
|
-
const svc = app.get("console.services.market");
|
|
941
|
-
// ghost 有 registry 条目,但版本声明的 koishi peer 与 4.x 不相交
|
|
942
|
-
registryData["koishi-plugin-ghost"] = {
|
|
943
|
-
versions: {
|
|
944
|
-
"1.0.0": {
|
|
945
|
-
version: "1.0.0",
|
|
946
|
-
peerDependencies: { koishi: "^5.0.0" },
|
|
947
|
-
},
|
|
948
|
-
},
|
|
949
|
-
time: { "1.0.0": "2024-01-01T00:00:00Z" },
|
|
950
|
-
};
|
|
951
|
-
searchResponse = {
|
|
952
|
-
objects: [
|
|
953
|
-
{
|
|
954
|
-
package: {
|
|
955
|
-
name: "koishi-plugin-ghost",
|
|
956
|
-
version: "1.0.0",
|
|
957
|
-
date: "2024-01-01T00:00:00Z",
|
|
958
|
-
},
|
|
959
|
-
},
|
|
960
|
-
],
|
|
961
|
-
total: 1,
|
|
962
|
-
};
|
|
963
|
-
await svc?.start(true);
|
|
964
|
-
// collect 对 analyze 为即发即忘,等待逐包分析完成
|
|
965
|
-
await new Promise((resolve) =>
|
|
966
|
-
setTimeout(resolve, 300),
|
|
967
|
-
);
|
|
968
|
-
const payload = await svc?.get();
|
|
969
|
-
// 无兼容版本:对象标记 ignored,不进入数据缓存。
|
|
970
|
-
// progress 恒为 0:Scanner 以 defineProperty 定义 progress(不可写),
|
|
971
|
-
// analyze 收尾的自增在严格模式下抛错且被即发即忘吞掉(上游行为)。
|
|
972
|
-
expect(payload?.data).toEqual({});
|
|
973
|
-
expect(payload?.failed).toBe(0);
|
|
974
|
-
expect(payload?.total).toBe(1);
|
|
975
|
-
expect(payload?.progress).toBe(0);
|
|
976
|
-
delete registryData["koishi-plugin-ghost"];
|
|
977
|
-
});
|
|
978
|
-
|
|
979
|
-
itQuiet(
|
|
980
|
-
"搜索结果中被限流的包经重试后计入 failed(onFailure)",
|
|
981
|
-
async () => {
|
|
982
|
-
const svc = app.get("console.services.market");
|
|
983
|
-
searchResponse = {
|
|
984
|
-
objects: [
|
|
985
|
-
{
|
|
986
|
-
package: {
|
|
987
|
-
name: "koishi-plugin-ratelimited",
|
|
988
|
-
version: "1.0.0",
|
|
989
|
-
date: "2024-01-01T00:00:00Z",
|
|
990
|
-
},
|
|
991
|
-
},
|
|
992
|
-
],
|
|
993
|
-
total: 1,
|
|
994
|
-
};
|
|
995
|
-
await svc?.start(true);
|
|
996
|
-
// 等待限流重试(Retry-After 10ms × 3 次)与即发即忘的 analyze。
|
|
997
|
-
// 注意不能经 get() 断言:super.start() 会清空 _task,get() 触发的
|
|
998
|
-
// 二次 collect 会把 failed 重置(即发即忘的 analyze 尚未完成)。
|
|
999
|
-
await new Promise((resolve) =>
|
|
1000
|
-
setTimeout(resolve, 400),
|
|
1001
|
-
);
|
|
1002
|
-
// 不可达/被限流的包名进入 failed 列表(上一个用例中 registry
|
|
1003
|
-
// 条目已删除的 ghost 包经 404 路径同样落入此处)
|
|
1004
|
-
const provider = svc as unknown as {
|
|
1005
|
-
failed: string[];
|
|
1006
|
-
};
|
|
1007
|
-
expect(
|
|
1008
|
-
provider.failed.some((name) =>
|
|
1009
|
-
name.startsWith("koishi-plugin-"),
|
|
1010
|
-
),
|
|
1011
|
-
).toBe(true);
|
|
1012
|
-
},
|
|
1013
|
-
);
|
|
1014
|
-
|
|
1015
|
-
itQuiet(
|
|
1016
|
-
"plugin.upgrade 检出可升级项并输出确认提示",
|
|
1017
|
-
async () => {
|
|
1018
|
-
// 本地放置旧版安装,使 resolved 有值且低于远端 latest
|
|
1019
|
-
mkdirSync(
|
|
1020
|
-
join(tmp, "node_modules", "koishi-plugin-demo"),
|
|
1021
|
-
{
|
|
1022
|
-
recursive: true,
|
|
1023
|
-
},
|
|
1024
|
-
);
|
|
1025
|
-
writeFileSync(
|
|
1026
|
-
join(
|
|
1027
|
-
tmp,
|
|
1028
|
-
"node_modules",
|
|
1029
|
-
"koishi-plugin-demo",
|
|
1030
|
-
"package.json",
|
|
1031
|
-
),
|
|
1032
|
-
JSON.stringify({
|
|
1033
|
-
name: "koishi-plugin-demo",
|
|
1034
|
-
version: "1.0.0",
|
|
1035
|
-
}),
|
|
1036
|
-
);
|
|
1037
|
-
app.installer.refresh();
|
|
1038
|
-
|
|
1039
|
-
nextExitCode = 0;
|
|
1040
|
-
// 发出升级指令(异步等待确认),再以 Y 回复确认。
|
|
1041
|
-
// 注:mock 环境下指令 ctx 对 loader 服务的可见性受 cordis
|
|
1042
|
-
// isolate 语义限制(见仓库测试任务记录),确认后的安装段
|
|
1043
|
-
// 由 market/install 监听器用例覆盖。
|
|
1044
|
-
const question = client.receive(
|
|
1045
|
-
"plugin.upgrade demo",
|
|
1046
|
-
);
|
|
1047
|
-
await new Promise((resolve) =>
|
|
1048
|
-
setTimeout(resolve, 200),
|
|
1049
|
-
);
|
|
1050
|
-
await client.receive("Y");
|
|
1051
|
-
const replies = await question;
|
|
1052
|
-
const output = replies.join("\n");
|
|
1053
|
-
expect(output).toContain("koishi-plugin-demo");
|
|
1054
|
-
expect(output).toContain("1.0.0 -> 2.0.0");
|
|
1055
|
-
},
|
|
1056
|
-
20000,
|
|
1057
|
-
);
|
|
1058
|
-
|
|
1059
|
-
itQuiet(
|
|
1060
|
-
"plugin.upgrade 对本地畸形版本静默跳过(非法 semver catch)",
|
|
1061
|
-
async () => {
|
|
1062
|
-
// registry 侧提供合法 latest;本地安装产物的 version 是畸形串,
|
|
1063
|
-
// request(清单声明)合法故不标记 invalid,gt(latest, resolved)
|
|
1064
|
-
// 解析失败进入 catch 分支:该包被过滤,视为无可升级项
|
|
1065
|
-
registryData["koishi-plugin-weird"] = {
|
|
1066
|
-
versions: {
|
|
1067
|
-
"2.0.0": {
|
|
1068
|
-
version: "2.0.0",
|
|
1069
|
-
peerDependencies: { koishi: "^4.17.0" },
|
|
1070
|
-
},
|
|
1071
|
-
},
|
|
1072
|
-
};
|
|
1073
|
-
mkdirSync(
|
|
1074
|
-
join(tmp, "node_modules", "koishi-plugin-weird"),
|
|
1075
|
-
{
|
|
1076
|
-
recursive: true,
|
|
1077
|
-
},
|
|
1078
|
-
);
|
|
1079
|
-
writeFileSync(
|
|
1080
|
-
join(
|
|
1081
|
-
tmp,
|
|
1082
|
-
"node_modules",
|
|
1083
|
-
"koishi-plugin-weird",
|
|
1084
|
-
"package.json",
|
|
1085
|
-
),
|
|
1086
|
-
JSON.stringify({
|
|
1087
|
-
name: "koishi-plugin-weird",
|
|
1088
|
-
version: "not.a.version",
|
|
1089
|
-
}),
|
|
1090
|
-
);
|
|
1091
|
-
nextExitCode = 0;
|
|
1092
|
-
// 经 install 注入清单声明(顺带刷新 Installer 的 manifest 快照)
|
|
1093
|
-
const code = await app.installer.install({
|
|
1094
|
-
"koishi-plugin-weird": "1.0.0",
|
|
1095
|
-
});
|
|
1096
|
-
expect(code).toBe(0);
|
|
1097
|
-
const deps = await app.installer.getDeps();
|
|
1098
|
-
expect(deps["koishi-plugin-weird"]?.resolved).toBe(
|
|
1099
|
-
"not.a.version",
|
|
1100
|
-
);
|
|
1101
|
-
const replies = await client.receive(
|
|
1102
|
-
"plugin.upgrade weird",
|
|
1103
|
-
);
|
|
1104
|
-
expect(replies[0]).toContain(
|
|
1105
|
-
"所有插件已是最新版本。",
|
|
1106
|
-
);
|
|
1107
|
-
},
|
|
1108
|
-
20000,
|
|
1109
|
-
);
|
|
1110
|
-
});
|
|
1111
|
-
|
|
1112
|
-
describe("镜像索引瞬态重试", () => {
|
|
1113
|
-
/** 搭建配了 search.endpoint 的独立宿主(镜像索引经本测试桩提供)。 */
|
|
1114
|
-
async function createMirrorApp(search: {
|
|
1115
|
-
endpoint: string;
|
|
1116
|
-
timeout?: number;
|
|
1117
|
-
}) {
|
|
1118
|
-
const mirror = new App();
|
|
1119
|
-
mirror.plugin(memory);
|
|
1120
|
-
mirror.plugin(http);
|
|
1121
|
-
mirror.plugin(
|
|
1122
|
-
FakeConsole as unknown as Plugin.Constructor<TestApp>,
|
|
1123
|
-
);
|
|
1124
|
-
mirror.plugin(FakeLoader);
|
|
1125
|
-
mirror.plugin(market, {
|
|
1126
|
-
registry: {
|
|
1127
|
-
endpoint: `http://127.0.0.1:${registryServer.port}/`,
|
|
1128
|
-
},
|
|
1129
|
-
search,
|
|
1130
|
-
});
|
|
1131
|
-
await mirror.start();
|
|
1132
|
-
return mirror;
|
|
1133
|
-
}
|
|
1134
|
-
|
|
1135
|
-
it("镜像索引部署窗口 404 经退避重试后恢复", async () => {
|
|
1136
|
-
flakyIndexHits = 0;
|
|
1137
|
-
flakyIndexMisses = 2;
|
|
1138
|
-
const mirror = await createMirrorApp({
|
|
1139
|
-
endpoint: `http://127.0.0.1:${registryServer.port}/flaky-index`,
|
|
1140
|
-
});
|
|
1141
|
-
const svc = mirror.get("console.services.market");
|
|
1142
|
-
// start(true) 内部 await prepare:前两次 404,退避重试后第三次成功
|
|
1143
|
-
await svc?.start(true);
|
|
1144
|
-
expect(flakyIndexHits).toBeGreaterThanOrEqual(3);
|
|
1145
|
-
const payload = await svc?.get();
|
|
1146
|
-
expect(Object.keys(payload?.data ?? {})).toEqual([
|
|
1147
|
-
"koishi-plugin-demo",
|
|
1148
|
-
]);
|
|
1149
|
-
expect(payload?.total).toBe(1);
|
|
1150
|
-
await mirror.stop();
|
|
1151
|
-
}, 10000);
|
|
1152
|
-
|
|
1153
|
-
itQuiet(
|
|
1154
|
-
"镜像索引持续超时经重试后优雅失败",
|
|
1155
|
-
async () => {
|
|
1156
|
-
slowIndexHits = 0;
|
|
1157
|
-
const mirror = await createMirrorApp({
|
|
1158
|
-
endpoint: `http://127.0.0.1:${registryServer.port}/slow-index`,
|
|
1159
|
-
timeout: 50,
|
|
1160
|
-
});
|
|
1161
|
-
const svc = mirror.get("console.services.market");
|
|
1162
|
-
// 每次尝试 50ms 即超时,退避 1s + 2s 后仍失败;重试确实发生
|
|
1163
|
-
await svc?.start(true);
|
|
1164
|
-
expect(slowIndexHits).toBeGreaterThanOrEqual(3);
|
|
1165
|
-
// start 末尾的 refresh 会再触发一轮 collect,等待其失败落地
|
|
1166
|
-
await new Promise((resolve) =>
|
|
1167
|
-
setTimeout(resolve, 3600),
|
|
1168
|
-
);
|
|
1169
|
-
const error = svc?.["_error" as keyof typeof svc];
|
|
1170
|
-
expect(error).toBeDefined();
|
|
1171
|
-
await mirror.stop();
|
|
1172
|
-
},
|
|
1173
|
-
15000,
|
|
1174
|
-
);
|
|
1175
|
-
|
|
1176
|
-
// 裸 TCP 桩:前 planned 次请求收到请求头后直接销毁连接(复现
|
|
1177
|
-
// 证书验证失败 / 连接重置等 fetch 网络层瞬态错误),随后回正常索引
|
|
1178
|
-
function startFlakyTcpServer(planned: number) {
|
|
1179
|
-
let hits = 0;
|
|
1180
|
-
const server = net.createServer((socket) => {
|
|
1181
|
-
socket.on("data", () => {
|
|
1182
|
-
hits++;
|
|
1183
|
-
if (hits <= planned) {
|
|
1184
|
-
socket.destroy();
|
|
1185
|
-
return;
|
|
1186
|
-
}
|
|
1187
|
-
const body = JSON.stringify({
|
|
1188
|
-
version: "4",
|
|
1189
|
-
objects: [
|
|
1190
|
-
{
|
|
1191
|
-
package: {
|
|
1192
|
-
name: "koishi-plugin-demo",
|
|
1193
|
-
version: "2.0.0",
|
|
1194
|
-
date: "2024-06-01T00:00:00Z",
|
|
1195
|
-
keywords: ["koishi", "plugin"],
|
|
1196
|
-
},
|
|
1197
|
-
},
|
|
1198
|
-
],
|
|
1199
|
-
});
|
|
1200
|
-
socket.end(
|
|
1201
|
-
`HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: ${body.length}\r\nConnection: close\r\n\r\n${body}`,
|
|
1202
|
-
);
|
|
1203
|
-
});
|
|
1204
|
-
});
|
|
1205
|
-
return new Promise<{
|
|
1206
|
-
port: number;
|
|
1207
|
-
hits: () => number;
|
|
1208
|
-
close: () => void;
|
|
1209
|
-
}>((resolve) => {
|
|
1210
|
-
server.listen(0, "127.0.0.1", () => {
|
|
1211
|
-
const { port } =
|
|
1212
|
-
server.address() as net.AddressInfo;
|
|
1213
|
-
resolve({
|
|
1214
|
-
port,
|
|
1215
|
-
hits: () => hits,
|
|
1216
|
-
close: () => {
|
|
1217
|
-
server.close();
|
|
1218
|
-
},
|
|
1219
|
-
});
|
|
1220
|
-
});
|
|
1221
|
-
});
|
|
1222
|
-
}
|
|
1223
|
-
|
|
1224
|
-
it("网络层瞬态错误(连接重置)经退避重试后恢复", async () => {
|
|
1225
|
-
const stub = await startFlakyTcpServer(2);
|
|
1226
|
-
const mirror = await createMirrorApp({
|
|
1227
|
-
endpoint: `http://127.0.0.1:${stub.port}/`,
|
|
1228
|
-
});
|
|
1229
|
-
const svc = mirror.get("console.services.market");
|
|
1230
|
-
// 前两次连接被重置(fetch 抛 TypeError,plugin-http 包装为无 code
|
|
1231
|
-
// 无 response 的 HTTP.Error),退避重试后第三次成功
|
|
1232
|
-
await svc?.start(true);
|
|
1233
|
-
expect(stub.hits()).toBeGreaterThanOrEqual(3);
|
|
1234
|
-
const payload = await svc?.get();
|
|
1235
|
-
expect(Object.keys(payload?.data ?? {})).toEqual([
|
|
1236
|
-
"koishi-plugin-demo",
|
|
1237
|
-
]);
|
|
1238
|
-
expect(payload?.total).toBe(1);
|
|
1239
|
-
await mirror.stop();
|
|
1240
|
-
stub.close();
|
|
1241
|
-
}, 10000);
|
|
1242
|
-
});
|