@koishi-ce/plugin-market 1.0.8 → 1.0.10

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.
@@ -2,28 +2,40 @@
2
2
  // Copyright (c) 2019-present Shigma and Koishijs contributors.
3
3
  // Copyright (c) 2026-present Koishi-CE contributors.
4
4
 
5
- import { afterAll, beforeAll, describe, expect, it, mock } from "bun:test";
6
- import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
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";
7
19
  import { tmpdir } from "node:os";
8
20
  import { join } from "node:path";
9
21
  import memory from "@koishijs/plugin-database-memory";
10
22
 
11
23
  /**
12
24
  * market 插件测试:
13
- * - 子进程(npm/yarn 安装)经 mock.module 拦截 execa,不落盘不联网安装;
25
+ * - 子进程(bun 安装)经 mock.module 拦截本地 proc.ts(spawnBun 封装),不落盘不联网安装;
14
26
  * - registry 网络请求由进程内 Bun.serve 提供(registry 协议的最小 JSON);
15
27
  * - 宿主环境(loader / cwd)使用 FakeLoader 与临时目录 + chdir,
16
28
  * Installer 的 override 写盘只会作用于临时 package.json。
17
29
  */
18
30
 
19
- /** 已发生的子进程调用(命令与参数)。 */
31
+ /** 已发生的子进程调用(参数列表)。 */
20
32
  const spawnCalls: string[][] = [];
21
33
  /** 控制下一次子进程的退出码与触发事件。 */
22
34
  let nextExitCode = 0;
23
35
  let nextSpawnError = false;
24
36
 
25
- const execaMock = (name: string, args: string[]) => {
26
- spawnCalls.push([name, ...args]);
37
+ const spawnBunMock = (_args: string[], _cwd: string) => {
38
+ spawnCalls.push(_args);
27
39
  return {
28
40
  on(event: string, cb: (code?: number) => void) {
29
41
  if (nextSpawnError) {
@@ -35,24 +47,25 @@ const execaMock = (name: string, args: string[]) => {
35
47
  },
36
48
  stderr: {
37
49
  on(event: string, cb: (data: Buffer) => void) {
38
- if (event === "data") {
39
- setImmediate(() => cb(Buffer.from("stderr line\n")));
40
- }
50
+ // 同步派发:异步派发会晚于用例的日志静默窗口,导致转发告警漏出刷屏
51
+ if (event === "data")
52
+ cb(Buffer.from("stderr line\n"));
41
53
  return this;
42
54
  },
43
55
  },
44
56
  stdout: {
45
57
  on(event: string, cb: (data: Buffer) => void) {
46
- if (event === "data") {
47
- setImmediate(() => cb(Buffer.from("stdout line\n")));
48
- }
58
+ if (event === "data")
59
+ cb(Buffer.from("stdout line\n"));
49
60
  return this;
50
61
  },
51
62
  },
52
63
  };
53
64
  };
54
65
 
55
- mock.module("execa", () => ({ default: execaMock }));
66
+ mock.module("../node/proc.ts", () => ({
67
+ spawnBun: spawnBunMock,
68
+ }));
56
69
 
57
70
  import type { Entry } from "@koishi-ce/console";
58
71
  // 均为 type-only 导入:编译期擦除,不干扰 mock.module 先于插件加载的时序
@@ -60,18 +73,29 @@ import type { Plugin } from "@koishi-ce/koishi";
60
73
  import type { RemotePackage } from "@koishi-ce/registry";
61
74
 
62
75
  const { Console } = await import("@koishi-ce/console");
63
- const { App, Service } = await import("@koishi-ce/koishi");
64
- const http = (await import("@koishi-ce/plugin-http")).default;
65
- const { isResidentInCache } = await import("@koishi-ce/registry");
76
+ const { App, Logger, Service } = await import(
77
+ "@koishi-ce/koishi"
78
+ );
79
+ const http = (await import("@koishi-ce/plugin-http"))
80
+ .default;
81
+ const { isResidentInCache } = await import(
82
+ "@koishi-ce/registry"
83
+ );
66
84
  const market = await import("../node/index.ts");
67
- const { default: Installer } = await import("../node/installer.ts");
68
- const mockPlugin = (await import("@koishi-ce/plugin-mock")).default;
85
+ const { default: Installer } = await import(
86
+ "../node/installer.ts"
87
+ );
88
+ const mockPlugin = (await import("@koishi-ce/plugin-mock"))
89
+ .default;
69
90
  // 加载包入口占位文件(纯 re-export,无独立逻辑),保证 src 全量被加载
70
91
  await import("../index.ts");
71
92
 
72
93
  /** 控制台服务桩:仅实现入口登记所需的最小面。 */
73
94
  class FakeConsole extends Console {
74
- protected resolveEntry(_files: Entry.Files, _key: string): string[] {
95
+ protected resolveEntry(
96
+ _files: Entry.Files,
97
+ _key: string,
98
+ ): string[] {
75
99
  return [];
76
100
  }
77
101
  }
@@ -84,7 +108,9 @@ class FakeLoader extends Service {
84
108
  return ["group:entry", "plugins"];
85
109
  }
86
110
  fullReload() {}
87
- constructor(ctx: ConstructorParameters<typeof Service>[0]) {
111
+ constructor(
112
+ ctx: ConstructorParameters<typeof Service>[0],
113
+ ) {
88
114
  super(ctx, "loader", true);
89
115
  }
90
116
  }
@@ -104,8 +130,37 @@ const registryData: {
104
130
  };
105
131
  } = {};
106
132
 
107
- /** 搜索接口响应(/-/v1/search,collect 阶段消费)。 */
108
- let searchResponse: { objects: unknown[]; total: number } | null = null;
133
+ /** 搜索接口响应(/-/v1/search,collect 阶段消费)。初始给空结果,避免启动期空转 404。 */
134
+ let searchResponse: {
135
+ objects: unknown[];
136
+ total: number;
137
+ } | null = {
138
+ objects: [],
139
+ total: 0,
140
+ };
141
+
142
+ /** 声明预期触发 market 域告警(404 / 限流等桩失败路径)的用例:执行期间静默该域。 */
143
+ const itQuiet = (
144
+ name: string,
145
+ fn: () => Promise<void> | void,
146
+ timeout?: number,
147
+ ) =>
148
+ it(
149
+ name,
150
+ async () => {
151
+ const levels = Logger.levels as Record<
152
+ string,
153
+ number
154
+ >;
155
+ levels["market"] = 0;
156
+ try {
157
+ await fn();
158
+ } finally {
159
+ delete levels["market"];
160
+ }
161
+ },
162
+ timeout,
163
+ );
109
164
 
110
165
  const registryServer = Bun.serve({
111
166
  port: 0,
@@ -119,7 +174,9 @@ const registryServer = Bun.serve({
119
174
  return Response.json(searchResponse);
120
175
  }
121
176
  // 模拟 registry 限流:恒定 429 + 极短的 Retry-After,驱动重试后失败
122
- if (url.pathname.includes("koishi-plugin-ratelimited")) {
177
+ if (
178
+ url.pathname.includes("koishi-plugin-ratelimited")
179
+ ) {
123
180
  return new Response("rate limited", {
124
181
  status: 429,
125
182
  headers: { "Retry-After": "0.01" },
@@ -137,18 +194,44 @@ const registryServer = Bun.serve({
137
194
  // 预置一个兼容插件包:最新版 2.0.0,旧版 1.0.0 均声明 koishi ^4 peer
138
195
  registryData["koishi-plugin-demo"] = {
139
196
  versions: {
140
- "1.0.0": { version: "1.0.0", peerDependencies: { koishi: "^4.17.0" } },
141
- "2.0.0": { version: "2.0.0", peerDependencies: { koishi: "^4.17.0" } },
197
+ "1.0.0": {
198
+ version: "1.0.0",
199
+ peerDependencies: { koishi: "^4.17.0" },
200
+ },
201
+ "2.0.0": {
202
+ version: "2.0.0",
203
+ peerDependencies: { koishi: "^4.17.0" },
204
+ },
205
+ },
206
+ time: {
207
+ "1.0.0": "2024-01-01T00:00:00Z",
208
+ "2.0.0": "2024-06-01T00:00:00Z",
142
209
  },
143
- time: { "1.0.0": "2024-01-01T00:00:00Z", "2.0.0": "2024-06-01T00:00:00Z" },
144
210
  };
145
211
  // 预置一个未安装的插件包(plugin.install 安装路径使用)
146
212
  registryData["koishi-plugin-newpkg"] = {
147
213
  versions: {
148
- "1.0.0": { version: "1.0.0", peerDependencies: { koishi: "^4.17.0" } },
214
+ "1.0.0": {
215
+ version: "1.0.0",
216
+ peerDependencies: { koishi: "^4.17.0" },
217
+ },
149
218
  },
150
219
  time: { "1.0.0": "2024-01-01T00:00:00Z" },
151
220
  };
221
+ // 预置初始清单里三个非插件依赖的远端条目:Installer.refresh() 每轮都会对全部
222
+ // 清单依赖发起 registry 查询,缺桩会让每个用例都刷 404 告警
223
+ registryData["koishi"] = {
224
+ versions: { "4.18.0": { version: "4.18.0" } },
225
+ time: { "4.18.0": "2024-01-01T00:00:00Z" },
226
+ };
227
+ registryData["market-alias"] = {
228
+ versions: { "1.0.0": { version: "1.0.0" } },
229
+ time: { "1.0.0": "2024-01-01T00:00:00Z" },
230
+ };
231
+ registryData["bad-range"] = {
232
+ versions: { "1.0.0": { version: "1.0.0" } },
233
+ time: { "1.0.0": "2024-01-01T00:00:00Z" },
234
+ };
152
235
 
153
236
  /** 临时宿主目录(Installer 的 cwd 与 override 写盘目标)。 */
154
237
  const initialDependencies = {
@@ -166,7 +249,10 @@ const originalCwd = process.cwd();
166
249
  writeFileSync(
167
250
  join(tmp, "package.json"),
168
251
  JSON.stringify(
169
- { name: "market-host", dependencies: initialDependencies },
252
+ {
253
+ name: "market-host",
254
+ dependencies: initialDependencies,
255
+ },
170
256
  null,
171
257
  "\t",
172
258
  ),
@@ -182,10 +268,14 @@ const app = new App();
182
268
  app.plugin(memory as unknown as typeof memory.default);
183
269
  app.plugin(http);
184
270
  // Console 基类的 static inject 是 cordis 3 旧形态,与 Plugin.Constructor 期待类型不兼容,仅做类型层转型
185
- app.plugin(FakeConsole as unknown as Plugin.Constructor<TestApp>);
271
+ app.plugin(
272
+ FakeConsole as unknown as Plugin.Constructor<TestApp>,
273
+ );
186
274
  app.plugin(FakeLoader);
187
275
  app.plugin(market, {
188
- registry: { endpoint: `http://127.0.0.1:${registryServer.port}/` },
276
+ registry: {
277
+ endpoint: `http://127.0.0.1:${registryServer.port}/`,
278
+ },
189
279
  });
190
280
  app.plugin(mockPlugin);
191
281
 
@@ -207,21 +297,31 @@ afterAll(async () => {
207
297
 
208
298
  describe("market 插件", () => {
209
299
  it("注册三个数据服务与浏览器监听器", () => {
210
- expect(app.get("console.services.market")).toBeDefined();
211
- expect(app.get("console.services.dependencies")).toBeDefined();
212
- expect(app.get("console.services.registry")).toBeDefined();
213
- expect(app.console.listeners["market/install"]).toBeDefined();
214
- expect(app.console.listeners["market/registry"]).toBeDefined();
300
+ expect(
301
+ app.get("console.services.market"),
302
+ ).toBeDefined();
303
+ expect(
304
+ app.get("console.services.dependencies"),
305
+ ).toBeDefined();
306
+ expect(
307
+ app.get("console.services.registry"),
308
+ ).toBeDefined();
309
+ expect(
310
+ app.console.listeners["market/install"],
311
+ ).toBeDefined();
312
+ expect(
313
+ app.console.listeners["market/registry"],
314
+ ).toBeDefined();
215
315
  });
216
316
 
217
317
  it("resolveName 解析插件短名的候选全名", () => {
218
318
  const installer = app.installer;
219
- expect(installer.resolveName("@koishijs/plugin-echo")).toEqual([
220
- "@koishijs/plugin-echo",
221
- ]);
222
- expect(installer.resolveName("koishi-plugin-echo")).toEqual([
223
- "koishi-plugin-echo",
224
- ]);
319
+ expect(
320
+ installer.resolveName("@koishijs/plugin-echo"),
321
+ ).toEqual(["@koishijs/plugin-echo"]);
322
+ expect(
323
+ installer.resolveName("koishi-plugin-echo"),
324
+ ).toEqual(["koishi-plugin-echo"]);
225
325
  expect(installer.resolveName("@scope/echo")).toEqual([
226
326
  "@scope/koishi-plugin-echo",
227
327
  ]);
@@ -234,27 +334,40 @@ describe("market 插件", () => {
234
334
  it("getDeps 汇总本地依赖并带出远端最新版", async () => {
235
335
  const deps = await app.installer.getDeps();
236
336
  // 语义化区间去除前缀符号
237
- expect(deps["koishi-plugin-demo"]?.request).toBe("1.0.0");
337
+ expect(deps["koishi-plugin-demo"]?.request).toBe(
338
+ "1.0.0",
339
+ );
238
340
  // 远端最新版(本地 registry 预置 2.0.0)
239
- expect(deps["koishi-plugin-demo"]?.latest).toBe("2.0.0");
341
+ expect(deps["koishi-plugin-demo"]?.latest).toBe(
342
+ "2.0.0",
343
+ );
240
344
  // 非法 semver 标记 invalid
241
345
  expect(deps["bad-range"]?.invalid).toBe(true);
242
346
  });
243
347
 
244
- it("findVersion 返回首个存在的候选包版本", async () => {
245
- const found = await app.installer.findVersion([
246
- "@koishijs/plugin-none",
247
- "koishi-plugin-demo",
248
- ]);
249
- expect(found).toEqual({ "koishi-plugin-demo": "2.0.0" });
250
- // 全部不存在时返回 undefined
251
- expect(
252
- await app.installer.findVersion(["@koishijs/plugin-none"]),
253
- ).toBeUndefined();
254
- });
348
+ itQuiet(
349
+ "findVersion 返回首个存在的候选包版本",
350
+ async () => {
351
+ const found = await app.installer.findVersion([
352
+ "@koishijs/plugin-none",
353
+ "koishi-plugin-demo",
354
+ ]);
355
+ expect(found).toEqual({
356
+ "koishi-plugin-demo": "2.0.0",
357
+ });
358
+ // 全部不存在时返回 undefined
359
+ expect(
360
+ await app.installer.findVersion([
361
+ "@koishijs/plugin-none",
362
+ ]),
363
+ ).toBeUndefined();
364
+ },
365
+ );
255
366
 
256
- it("getPackage 拉取失败时回退为空表", async () => {
257
- const versions = await app.installer.getPackage("koishi-plugin-missing");
367
+ itQuiet("getPackage 拉取失败时回退为空表", async () => {
368
+ const versions = await app.installer.getPackage(
369
+ "koishi-plugin-missing",
370
+ );
258
371
  expect(versions).toEqual({});
259
372
  });
260
373
 
@@ -267,60 +380,79 @@ describe("market 插件", () => {
267
380
  } as unknown as RemotePackage,
268
381
  ]);
269
382
  expect(
270
- Object.keys(app.installer.fullCache["koishi-plugin-demo"] ?? {}),
383
+ Object.keys(
384
+ app.installer.fullCache["koishi-plugin-demo"] ?? {},
385
+ ),
271
386
  ).toEqual(["3.0.0"]);
272
387
  // 等待节流窗口
273
- await new Promise((resolve) => setTimeout(resolve, 600));
388
+ await new Promise((resolve) =>
389
+ setTimeout(resolve, 600),
390
+ );
274
391
  });
275
392
  });
276
393
 
277
394
  describe("Installer 安装链路", () => {
278
- it("install 尊重护栏依赖并执行子进程安装", async () => {
279
- nextExitCode = 0;
280
- spawnCalls.length = 0;
281
- const code = await app.installer.install({
282
- koishi: "2.0.0",
283
- "market-alias": null,
284
- "koishi-plugin-demo": "^1.0.0",
285
- });
286
- expect(code).toBe(0);
287
- // 触发了包管理器安装(npm install --registry …)
288
- expect(spawnCalls.length).toBe(1);
289
- expect(spawnCalls[0]?.[0]).toBe("npm");
290
- expect(spawnCalls[0]?.[1]).toBe("install");
291
- // 重新读取临时 package.json:护栏项保持原样,新依赖加入
292
- const manifest = JSON.parse(
293
- await Bun.file(join(tmp, "package.json")).text(),
294
- ) as { dependencies: Record<string, string> };
295
- expect(manifest.dependencies["koishi"]).toBe("workspace:*");
296
- expect(manifest.dependencies["market-alias"]).toBe(
297
- "npm:@koishi-ce/anything@^1.0.0",
298
- );
299
- expect(manifest.dependencies["koishi-plugin-demo"]).toBe("^1.0.0");
300
- }, 15000);
301
-
302
- it("install 强制时无视本地满足也要装", async () => {
303
- nextExitCode = 0;
304
- spawnCalls.length = 0;
305
- const code = await app.installer.install(
306
- { "koishi-plugin-demo": "^1.0.0" },
307
- true,
308
- );
309
- expect(code).toBe(0);
310
- expect(spawnCalls.length).toBe(1);
311
- }, 15000);
312
-
313
- it("子进程非零退出码向上传递", async () => {
314
- nextExitCode = 1;
315
- spawnCalls.length = 0;
316
- const code = await app.installer.install(
317
- { "koishi-plugin-demo": "^2.0.0" },
318
- true,
319
- );
320
- expect(code).toBe(1);
321
- }, 15000);
395
+ itQuiet(
396
+ "install 尊重护栏依赖并执行子进程安装",
397
+ async () => {
398
+ nextExitCode = 0;
399
+ spawnCalls.length = 0;
400
+ const code = await app.installer.install({
401
+ koishi: "2.0.0",
402
+ "market-alias": null,
403
+ "koishi-plugin-demo": "^1.0.0",
404
+ });
405
+ expect(code).toBe(0);
406
+ // 触发了包管理器安装(bun install --registry …)
407
+ expect(spawnCalls.length).toBe(1);
408
+ expect(spawnCalls[0]?.[0]).toBe("install");
409
+ // 重新读取临时 package.json:护栏项保持原样,新依赖加入
410
+ const manifest = JSON.parse(
411
+ await Bun.file(join(tmp, "package.json")).text(),
412
+ ) as { dependencies: Record<string, string> };
413
+ expect(manifest.dependencies["koishi"]).toBe(
414
+ "workspace:*",
415
+ );
416
+ expect(manifest.dependencies["market-alias"]).toBe(
417
+ "npm:@koishi-ce/anything@^1.0.0",
418
+ );
419
+ expect(
420
+ manifest.dependencies["koishi-plugin-demo"],
421
+ ).toBe("^1.0.0");
422
+ },
423
+ 15000,
424
+ );
425
+
426
+ itQuiet(
427
+ "install 强制时无视本地满足也要装",
428
+ async () => {
429
+ nextExitCode = 0;
430
+ spawnCalls.length = 0;
431
+ const code = await app.installer.install(
432
+ { "koishi-plugin-demo": "^1.0.0" },
433
+ true,
434
+ );
435
+ expect(code).toBe(0);
436
+ expect(spawnCalls.length).toBe(1);
437
+ },
438
+ 15000,
439
+ );
440
+
441
+ itQuiet(
442
+ "子进程非零退出码向上传递",
443
+ async () => {
444
+ nextExitCode = 1;
445
+ spawnCalls.length = 0;
446
+ const code = await app.installer.install(
447
+ { "koishi-plugin-demo": "^2.0.0" },
448
+ true,
449
+ );
450
+ expect(code).toBe(1);
451
+ },
452
+ 15000,
453
+ );
322
454
 
323
- it("子进程 spawn 失败返回 -1", async () => {
455
+ itQuiet("子进程 spawn 失败返回 -1", async () => {
324
456
  nextSpawnError = true;
325
457
  nextExitCode = 0;
326
458
  const code = await app.installer.exec(["install"]);
@@ -328,12 +460,15 @@ describe("Installer 安装链路", () => {
328
460
  expect(code).toBe(-1);
329
461
  });
330
462
 
331
- it("exec 收集子进程 stdout / stderr 输出行", async () => {
332
- nextExitCode = 0;
333
- nextSpawnError = false;
334
- const code = await app.installer.exec(["install"]);
335
- expect(code).toBe(0);
336
- });
463
+ itQuiet(
464
+ "exec 收集子进程 stdout / stderr 输出行",
465
+ async () => {
466
+ nextExitCode = 0;
467
+ nextSpawnError = false;
468
+ const code = await app.installer.exec(["install"]);
469
+ expect(code).toBe(0);
470
+ },
471
+ );
337
472
  });
338
473
 
339
474
  describe("isResidentInCache(装后重载判定)", () => {
@@ -343,9 +478,16 @@ describe("isResidentInCache(装后重载判定)", () => {
343
478
  mkdirSync(pkgDir, { recursive: true });
344
479
  writeFileSync(
345
480
  join(pkgDir, "package.json"),
346
- JSON.stringify({ name, version: "1.0.0", main: "index.js" }),
481
+ JSON.stringify({
482
+ name,
483
+ version: "1.0.0",
484
+ main: "index.js",
485
+ }),
486
+ );
487
+ writeFileSync(
488
+ join(pkgDir, "index.js"),
489
+ "module.exports = {}",
347
490
  );
348
- writeFileSync(join(pkgDir, "index.js"), "module.exports = {}");
349
491
  return pkgDir;
350
492
  }
351
493
 
@@ -353,16 +495,22 @@ describe("isResidentInCache(装后重载判定)", () => {
353
495
  const pkgDir = placePkg("koishi-plugin-resident-check");
354
496
  // 入口经 require 进入 require.cache,模拟旧版本驻留内存
355
497
  require(join(pkgDir, "index.js"));
356
- expect(isResidentInCache("koishi-plugin-resident-check")).toBe(true);
498
+ expect(
499
+ isResidentInCache("koishi-plugin-resident-check"),
500
+ ).toBe(true);
357
501
  });
358
502
 
359
503
  it("已安装但无模块驻留内存时返回 false", () => {
360
504
  placePkg("koishi-plugin-idle-check");
361
- expect(isResidentInCache("koishi-plugin-idle-check")).toBe(false);
505
+ expect(
506
+ isResidentInCache("koishi-plugin-idle-check"),
507
+ ).toBe(false);
362
508
  });
363
509
 
364
510
  it("包不存在时保守返回 true(宁可多重载不漏判)", () => {
365
- expect(isResidentInCache("koishi-plugin-absent-check")).toBe(true);
511
+ expect(
512
+ isResidentInCache("koishi-plugin-absent-check"),
513
+ ).toBe(true);
366
514
  });
367
515
  });
368
516
 
@@ -390,7 +538,9 @@ describe("registry 配置探测", () => {
390
538
  app3.plugin(http);
391
539
  app3.plugin(Installer, {});
392
540
  await app3.start();
393
- expect(app3.installer.endpoint).toBe("http://registry.example.npm/");
541
+ expect(app3.installer.endpoint).toBe(
542
+ "http://registry.example.npm/",
543
+ );
394
544
  await app3.stop();
395
545
  rmSync(join(tmp, ".npmrc"), { force: true });
396
546
  });
@@ -411,109 +561,178 @@ describe("MarketProvider 市场数据服务", () => {
411
561
  keywords: ["koishi", "plugin", "Tool"],
412
562
  },
413
563
  },
414
- { package: { name: "not-a-plugin", date: "2024-01-01T00:00:00Z" } },
564
+ {
565
+ package: {
566
+ name: "not-a-plugin",
567
+ date: "2024-01-01T00:00:00Z",
568
+ },
569
+ },
415
570
  ],
416
571
  total: 1,
417
572
  };
418
573
  // start(true) 强制刷新市场数据(重新 collect)
419
574
  await svc?.start(true);
420
575
  // 等待节流窗口与逐包分析完成
421
- await new Promise((resolve) => setTimeout(resolve, 700));
576
+ await new Promise((resolve) =>
577
+ setTimeout(resolve, 700),
578
+ );
422
579
  const payload = await svc?.get();
423
580
  expect(payload).toBeDefined();
424
581
  // 非 plugin 条目被剔除,只保留 demo
425
- expect(Object.keys(payload?.data ?? {})).toEqual(["koishi-plugin-demo"]);
582
+ expect(Object.keys(payload?.data ?? {})).toEqual([
583
+ "koishi-plugin-demo",
584
+ ]);
426
585
  expect(payload?.total).toBe(1);
427
586
  expect(payload?.failed).toBe(0);
428
- expect(payload?.registry).toBe(`http://127.0.0.1:${registryServer.port}/`);
587
+ expect(payload?.registry).toBe(
588
+ `http://127.0.0.1:${registryServer.port}/`,
589
+ );
429
590
  });
430
591
 
431
592
  it("依赖 / 注册表数据服务读取安装器缓存", async () => {
432
- const dependencies = await app.get("console.services.dependencies")?.get();
433
- expect(dependencies?.["koishi-plugin-demo"]?.request).toBeTruthy();
434
- expect(dependencies?.["koishi-plugin-demo"]?.latest).toBe("2.0.0");
593
+ const dependencies = await app
594
+ .get("console.services.dependencies")
595
+ ?.get();
596
+ expect(
597
+ dependencies?.["koishi-plugin-demo"]?.request,
598
+ ).toBeTruthy();
599
+ expect(
600
+ dependencies?.["koishi-plugin-demo"]?.latest,
601
+ ).toBe("2.0.0");
435
602
 
436
- const registry = await app.get("console.services.registry")?.get();
437
- expect(Object.keys(registry?.["koishi-plugin-demo"] ?? {})).toContain(
438
- "2.0.0",
439
- );
603
+ const registry = await app
604
+ .get("console.services.registry")
605
+ ?.get();
606
+ expect(
607
+ Object.keys(registry?.["koishi-plugin-demo"] ?? {}),
608
+ ).toContain("2.0.0");
440
609
  });
441
610
 
442
- it("搜索接口失败时 get 返回空数据与错误标记", async () => {
443
- const svc = app.get("console.services.market");
444
- searchResponse = null;
445
- // 强制重扫:collect 失败置 _error,get 返回空 payload
446
- await svc?.start(true);
447
- const payload = await svc?.get();
448
- expect(payload).toEqual({ data: {}, failed: 0, total: 0, progress: 0 });
449
- searchResponse = {
450
- objects: [],
451
- total: 0,
452
- };
453
- });
611
+ itQuiet(
612
+ "搜索接口失败时 get 返回空数据与错误标记",
613
+ async () => {
614
+ const svc = app.get("console.services.market");
615
+ searchResponse = null;
616
+ // 强制重扫:collect 失败置 _error,get 返回空 payload
617
+ await svc?.start(true);
618
+ const payload = await svc?.get();
619
+ expect(payload).toEqual({
620
+ data: {},
621
+ failed: 0,
622
+ total: 0,
623
+ progress: 0,
624
+ });
625
+ searchResponse = {
626
+ objects: [],
627
+ total: 0,
628
+ };
629
+ },
630
+ );
454
631
 
455
632
  it("控制台连接事件在数据过期时触发刷新", async () => {
456
633
  const svc = app.get("console.services.market");
457
634
  expect(svc).toBeDefined();
458
635
  // 伪造一个在线客户端,使连接事件通过在线检查(broadcast 需可用的 socket)
459
- const fakeClient = { id: "conn-1", socket: { send() {} } };
460
- (app.console.clients as Record<string, unknown>)["conn-1"] = fakeClient;
636
+ const fakeClient = {
637
+ id: "conn-1",
638
+ socket: { send() {} },
639
+ };
640
+ (app.console.clients as Record<string, unknown>)[
641
+ "conn-1"
642
+ ] = fakeClient;
461
643
  // 刚刷新过:12 小时窗口内直接返回,不重新收集
462
- const timestamp = svc?.["_timestamp" as keyof typeof svc] as number;
644
+ const timestamp = svc?.[
645
+ "_timestamp" as keyof typeof svc
646
+ ] as number;
463
647
  // console/connection 载荷声明为 Client,桩对象仅含在线检查所需的最小面,类型层断言穿透
464
648
  app.emit("console/connection", fakeClient as never);
465
- expect(svc?.["_timestamp" as keyof typeof svc] as number).toBe(timestamp);
649
+ expect(
650
+ svc?.["_timestamp" as keyof typeof svc] as number,
651
+ ).toBe(timestamp);
466
652
  // 将时间戳回拨到窗口外,连接事件重新触发 start(异步监听,稍等)
467
653
  (svc as Record<string, unknown>)["_timestamp"] = 0;
468
654
  app.emit("console/connection", fakeClient as never);
469
655
  await new Promise((resolve) => setTimeout(resolve, 20));
470
656
  expect(
471
- (svc as Record<string, unknown>)["_timestamp"] as number,
657
+ (svc as Record<string, unknown>)[
658
+ "_timestamp"
659
+ ] as number,
472
660
  ).toBeGreaterThan(0);
473
661
  delete app.console.clients["conn-1"];
474
662
  });
475
663
  });
476
664
 
477
665
  describe("market 聊天指令", () => {
478
- it("plugin.install 缺参与未找到的报错路径", async () => {
479
- const missing = await client.receive("plugin.install");
480
- expect(missing[0]).toContain("请输入插件名。");
481
- const notFound = await client.receive("plugin.install absent-pkg");
482
- expect(notFound[0]).toContain("未找到该插件。");
483
- });
666
+ itQuiet(
667
+ "plugin.install 缺参与未找到的报错路径",
668
+ async () => {
669
+ const missing = await client.receive(
670
+ "plugin.install",
671
+ );
672
+ expect(missing[0]).toContain("请输入插件名。");
673
+ const notFound = await client.receive(
674
+ "plugin.install absent-pkg",
675
+ );
676
+ expect(notFound[0]).toContain("未找到该插件。");
677
+ },
678
+ );
484
679
 
485
680
  it("plugin.install 已安装时提示重复", async () => {
486
- const replies = await client.receive("plugin.install demo");
681
+ const replies = await client.receive(
682
+ "plugin.install demo",
683
+ );
487
684
  expect(replies[0]).toContain("该插件已安装。");
488
685
  });
489
686
 
490
- it("plugin.install 安装新插件并写入依赖", async () => {
491
- nextExitCode = 0;
492
- spawnCalls.length = 0;
493
- const replies = await client.receive("plugin.install newpkg");
494
- expect(replies[0]).toContain("安装成功!");
495
- expect(spawnCalls.length).toBe(1);
496
- const manifest = JSON.parse(
497
- await Bun.file(join(tmp, "package.json")).text(),
498
- ) as { dependencies: Record<string, string> };
499
- expect(manifest.dependencies["koishi-plugin-newpkg"]).toBe("1.0.0");
500
- // 重启消息在安装完成后复位(Loader 与桩形状不同,经 unknown 二段式断言)
501
- expect((app.loader as unknown as FakeLoader).envData["message"]).toBeNull();
502
- }, 15000);
503
-
504
- it("plugin.uninstall 卸载依赖并从清单移除", async () => {
505
- nextExitCode = 0;
506
- spawnCalls.length = 0;
507
- const replies = await client.receive("plugin.uninstall newpkg");
508
- expect(replies[0]).toContain("卸载成功!");
509
- const manifest = JSON.parse(
510
- await Bun.file(join(tmp, "package.json")).text(),
511
- ) as { dependencies: Record<string, string> };
512
- expect(manifest.dependencies["koishi-plugin-newpkg"]).toBeUndefined();
513
- }, 15000);
687
+ itQuiet(
688
+ "plugin.install 安装新插件并写入依赖",
689
+ async () => {
690
+ nextExitCode = 0;
691
+ spawnCalls.length = 0;
692
+ const replies = await client.receive(
693
+ "plugin.install newpkg",
694
+ );
695
+ expect(replies[0]).toContain("安装成功!");
696
+ expect(spawnCalls.length).toBe(1);
697
+ const manifest = JSON.parse(
698
+ await Bun.file(join(tmp, "package.json")).text(),
699
+ ) as { dependencies: Record<string, string> };
700
+ expect(
701
+ manifest.dependencies["koishi-plugin-newpkg"],
702
+ ).toBe("1.0.0");
703
+ // 重启消息在安装完成后复位(Loader 与桩形状不同,经 unknown 二段式断言)
704
+ expect(
705
+ (app.loader as unknown as FakeLoader).envData[
706
+ "message"
707
+ ],
708
+ ).toBeNull();
709
+ },
710
+ 15000,
711
+ );
712
+
713
+ itQuiet(
714
+ "plugin.uninstall 卸载依赖并从清单移除",
715
+ async () => {
716
+ nextExitCode = 0;
717
+ spawnCalls.length = 0;
718
+ const replies = await client.receive(
719
+ "plugin.uninstall newpkg",
720
+ );
721
+ expect(replies[0]).toContain("卸载成功!");
722
+ const manifest = JSON.parse(
723
+ await Bun.file(join(tmp, "package.json")).text(),
724
+ ) as { dependencies: Record<string, string> };
725
+ expect(
726
+ manifest.dependencies["koishi-plugin-newpkg"],
727
+ ).toBeUndefined();
728
+ },
729
+ 15000,
730
+ );
514
731
 
515
732
  it("plugin.uninstall 未安装时提示", async () => {
516
- const replies = await client.receive("plugin.uninstall absent-pkg");
733
+ const replies = await client.receive(
734
+ "plugin.uninstall absent-pkg",
735
+ );
517
736
  expect(replies[0]).toContain("该插件未安装。");
518
737
  });
519
738
 
@@ -524,48 +743,73 @@ describe("market 聊天指令", () => {
524
743
  });
525
744
 
526
745
  describe("market 进阶链路", () => {
527
- it("宿主配置不可写时不加载安装器", async () => {
528
- const appNoLoader = new App();
529
- appNoLoader.plugin(http);
530
- appNoLoader.plugin(FakeConsole as unknown as Plugin.Constructor<TestApp>);
531
- appNoLoader.plugin(market, {
532
- registry: { endpoint: `http://127.0.0.1:${registryServer.port}/` },
533
- });
534
- await appNoLoader.start();
535
- // apply loader 缺席时仅告警并提前返回
536
- expect(appNoLoader.installer).toBeUndefined();
537
- await appNoLoader.stop();
746
+ itQuiet("宿主配置不可写时不加载安装器", async () => {
747
+ // apply 的「仅告警并跳过」正是被测行为,静默 app 域避免预期告警刷屏
748
+ const levels = Logger.levels as Record<string, number>;
749
+ levels["app"] = 0;
750
+ try {
751
+ const appNoLoader = new App();
752
+ appNoLoader.plugin(http);
753
+ appNoLoader.plugin(
754
+ FakeConsole as unknown as Plugin.Constructor<TestApp>,
755
+ );
756
+ appNoLoader.plugin(market, {
757
+ registry: {
758
+ endpoint: `http://127.0.0.1:${registryServer.port}/`,
759
+ },
760
+ });
761
+ await appNoLoader.start();
762
+ // apply 在 loader 缺席时仅告警并提前返回
763
+ expect(appNoLoader.installer).toBeUndefined();
764
+ await appNoLoader.stop();
765
+ } finally {
766
+ delete levels["app"];
767
+ }
538
768
  });
539
769
 
540
- it("浏览器 market/install 监听器执行安装并刷新服务", async () => {
541
- nextExitCode = 0;
542
- const listener = app.console.listeners["market/install"];
543
- expect(listener).toBeDefined();
544
- const code = (await listener?.callback.call(
545
- {} as never,
546
- { "koishi-plugin-newpkg": "1.0.0" },
547
- true,
548
- )) as number;
549
- expect(code).toBe(0);
550
- }, 15000);
551
-
552
- it("浏览器 market/registry 监听器批量查询包元数据", async () => {
553
- const listener = app.console.listeners["market/registry"];
554
- expect(listener).toBeDefined();
555
- const meta = (await listener?.callback.call({} as never, [
556
- "koishi-plugin-demo",
557
- "koishi-plugin-missing",
558
- ])) as Record<string, unknown>;
559
- expect(Object.keys(meta["koishi-plugin-demo"] ?? {})).toContain("2.0.0");
560
- expect(meta["koishi-plugin-missing"]).toEqual({});
561
- });
770
+ itQuiet(
771
+ "浏览器 market/install 监听器执行安装并刷新服务",
772
+ async () => {
773
+ nextExitCode = 0;
774
+ const listener =
775
+ app.console.listeners["market/install"];
776
+ expect(listener).toBeDefined();
777
+ const code = (await listener?.callback.call(
778
+ {} as never,
779
+ { "koishi-plugin-newpkg": "1.0.0" },
780
+ true,
781
+ )) as number;
782
+ expect(code).toBe(0);
783
+ },
784
+ 15000,
785
+ );
786
+
787
+ itQuiet(
788
+ "浏览器 market/registry 监听器批量查询包元数据",
789
+ async () => {
790
+ const listener =
791
+ app.console.listeners["market/registry"];
792
+ expect(listener).toBeDefined();
793
+ const meta = (await listener?.callback.call(
794
+ {} as never,
795
+ ["koishi-plugin-demo", "koishi-plugin-missing"],
796
+ )) as Record<string, unknown>;
797
+ expect(
798
+ Object.keys(meta["koishi-plugin-demo"] ?? {}),
799
+ ).toContain("2.0.0");
800
+ expect(meta["koishi-plugin-missing"]).toEqual({});
801
+ },
802
+ );
562
803
 
563
804
  it("搜索结果中不兼容的包被跳过(analyze onSkipped/ignored)", async () => {
564
805
  const svc = app.get("console.services.market");
565
806
  // ghost 有 registry 条目,但版本声明的 koishi peer 与 4.x 不相交
566
807
  registryData["koishi-plugin-ghost"] = {
567
808
  versions: {
568
- "1.0.0": { version: "1.0.0", peerDependencies: { koishi: "^5.0.0" } },
809
+ "1.0.0": {
810
+ version: "1.0.0",
811
+ peerDependencies: { koishi: "^5.0.0" },
812
+ },
569
813
  },
570
814
  time: { "1.0.0": "2024-01-01T00:00:00Z" },
571
815
  };
@@ -583,7 +827,9 @@ describe("market 进阶链路", () => {
583
827
  };
584
828
  await svc?.start(true);
585
829
  // collect 对 analyze 为即发即忘,等待逐包分析完成
586
- await new Promise((resolve) => setTimeout(resolve, 300));
830
+ await new Promise((resolve) =>
831
+ setTimeout(resolve, 300),
832
+ );
587
833
  const payload = await svc?.get();
588
834
  // 无兼容版本:对象标记 ignored,不进入数据缓存。
589
835
  // progress 恒为 0:Scanner 以 defineProperty 定义 progress(不可写),
@@ -595,83 +841,135 @@ describe("market 进阶链路", () => {
595
841
  delete registryData["koishi-plugin-ghost"];
596
842
  });
597
843
 
598
- it("搜索结果中被限流的包经重试后计入 failed(onFailure)", async () => {
599
- const svc = app.get("console.services.market");
600
- searchResponse = {
601
- objects: [
844
+ itQuiet(
845
+ "搜索结果中被限流的包经重试后计入 failed(onFailure)",
846
+ async () => {
847
+ const svc = app.get("console.services.market");
848
+ searchResponse = {
849
+ objects: [
850
+ {
851
+ package: {
852
+ name: "koishi-plugin-ratelimited",
853
+ version: "1.0.0",
854
+ date: "2024-01-01T00:00:00Z",
855
+ },
856
+ },
857
+ ],
858
+ total: 1,
859
+ };
860
+ await svc?.start(true);
861
+ // 等待限流重试(Retry-After 10ms × 3 次)与即发即忘的 analyze。
862
+ // 注意不能经 get() 断言:super.start() 会清空 _task,get() 触发的
863
+ // 二次 collect 会把 failed 重置(即发即忘的 analyze 尚未完成)。
864
+ await new Promise((resolve) =>
865
+ setTimeout(resolve, 400),
866
+ );
867
+ // 不可达/被限流的包名进入 failed 列表(上一个用例中 registry
868
+ // 条目已删除的 ghost 包经 404 路径同样落入此处)
869
+ const provider = svc as unknown as {
870
+ failed: string[];
871
+ };
872
+ expect(
873
+ provider.failed.some((name) =>
874
+ name.startsWith("koishi-plugin-"),
875
+ ),
876
+ ).toBe(true);
877
+ },
878
+ );
879
+
880
+ itQuiet(
881
+ "plugin.upgrade 检出可升级项并输出确认提示",
882
+ async () => {
883
+ // 本地放置旧版安装,使 resolved 有值且低于远端 latest
884
+ mkdirSync(
885
+ join(tmp, "node_modules", "koishi-plugin-demo"),
602
886
  {
603
- package: {
604
- name: "koishi-plugin-ratelimited",
605
- version: "1.0.0",
606
- date: "2024-01-01T00:00:00Z",
887
+ recursive: true,
888
+ },
889
+ );
890
+ writeFileSync(
891
+ join(
892
+ tmp,
893
+ "node_modules",
894
+ "koishi-plugin-demo",
895
+ "package.json",
896
+ ),
897
+ JSON.stringify({
898
+ name: "koishi-plugin-demo",
899
+ version: "1.0.0",
900
+ }),
901
+ );
902
+ app.installer.refresh();
903
+
904
+ nextExitCode = 0;
905
+ // 发出升级指令(异步等待确认),再以 Y 回复确认。
906
+ // 注:mock 环境下指令 ctx 对 loader 服务的可见性受 cordis
907
+ // isolate 语义限制(见仓库测试任务记录),确认后的安装段
908
+ // 由 market/install 监听器用例覆盖。
909
+ const question = client.receive(
910
+ "plugin.upgrade demo",
911
+ );
912
+ await new Promise((resolve) =>
913
+ setTimeout(resolve, 200),
914
+ );
915
+ await client.receive("Y");
916
+ const replies = await question;
917
+ const output = replies.join("\n");
918
+ expect(output).toContain("koishi-plugin-demo");
919
+ expect(output).toContain("1.0.0 -> 2.0.0");
920
+ },
921
+ 20000,
922
+ );
923
+
924
+ itQuiet(
925
+ "plugin.upgrade 对本地畸形版本静默跳过(非法 semver catch)",
926
+ async () => {
927
+ // registry 侧提供合法 latest;本地安装产物的 version 是畸形串,
928
+ // request(清单声明)合法故不标记 invalid,gt(latest, resolved)
929
+ // 解析失败进入 catch 分支:该包被过滤,视为无可升级项
930
+ registryData["koishi-plugin-weird"] = {
931
+ versions: {
932
+ "2.0.0": {
933
+ version: "2.0.0",
934
+ peerDependencies: { koishi: "^4.17.0" },
607
935
  },
608
936
  },
609
- ],
610
- total: 1,
611
- };
612
- await svc?.start(true);
613
- // 等待限流重试(Retry-After 10ms × 3 次)与即发即忘的 analyze。
614
- // 注意不能经 get() 断言:super.start() 会清空 _task,get() 触发的
615
- // 二次 collect 会把 failed 重置(即发即忘的 analyze 尚未完成)。
616
- await new Promise((resolve) => setTimeout(resolve, 400));
617
- // 不可达/被限流的包名进入 failed 列表(上一个用例中 registry
618
- // 条目已删除的 ghost 包经 404 路径同样落入此处)
619
- const provider = svc as unknown as { failed: string[] };
620
- expect(
621
- provider.failed.some((name) => name.startsWith("koishi-plugin-")),
622
- ).toBe(true);
623
- });
624
-
625
- it("plugin.upgrade 检出可升级项并输出确认提示", async () => {
626
- // 本地放置旧版安装,使 resolved 有值且低于远端 latest
627
- mkdirSync(join(tmp, "node_modules", "koishi-plugin-demo"), {
628
- recursive: true,
629
- });
630
- writeFileSync(
631
- join(tmp, "node_modules", "koishi-plugin-demo", "package.json"),
632
- JSON.stringify({ name: "koishi-plugin-demo", version: "1.0.0" }),
633
- );
634
- app.installer.refresh();
635
-
636
- nextExitCode = 0;
637
- // 发出升级指令(异步等待确认),再以 Y 回复确认。
638
- // 注:mock 环境下指令 ctx 对 loader 服务的可见性受 cordis
639
- // isolate 语义限制(见仓库测试任务记录),确认后的安装段
640
- // 由 market/install 监听器用例覆盖。
641
- const question = client.receive("plugin.upgrade demo");
642
- await new Promise((resolve) => setTimeout(resolve, 200));
643
- await client.receive("Y");
644
- const replies = await question;
645
- const output = replies.join("\n");
646
- expect(output).toContain("koishi-plugin-demo");
647
- expect(output).toContain("1.0.0 -> 2.0.0");
648
- }, 20000);
649
-
650
- it("plugin.upgrade 对本地畸形版本静默跳过(非法 semver catch)", async () => {
651
- // registry 侧提供合法 latest;本地安装产物的 version 是畸形串,
652
- // request(清单声明)合法故不标记 invalid,gt(latest, resolved)
653
- // 解析失败进入 catch 分支:该包被过滤,视为无可升级项
654
- registryData["koishi-plugin-weird"] = {
655
- versions: {
656
- "2.0.0": { version: "2.0.0", peerDependencies: { koishi: "^4.17.0" } },
657
- },
658
- };
659
- mkdirSync(join(tmp, "node_modules", "koishi-plugin-weird"), {
660
- recursive: true,
661
- });
662
- writeFileSync(
663
- join(tmp, "node_modules", "koishi-plugin-weird", "package.json"),
664
- JSON.stringify({ name: "koishi-plugin-weird", version: "not.a.version" }),
665
- );
666
- nextExitCode = 0;
667
- // 经 install 注入清单声明(顺带刷新 Installer 的 manifest 快照)
668
- const code = await app.installer.install({
669
- "koishi-plugin-weird": "1.0.0",
670
- });
671
- expect(code).toBe(0);
672
- const deps = await app.installer.getDeps();
673
- expect(deps["koishi-plugin-weird"]?.resolved).toBe("not.a.version");
674
- const replies = await client.receive("plugin.upgrade weird");
675
- expect(replies[0]).toContain("所有插件已是最新版本。");
676
- }, 20000);
937
+ };
938
+ mkdirSync(
939
+ join(tmp, "node_modules", "koishi-plugin-weird"),
940
+ {
941
+ recursive: true,
942
+ },
943
+ );
944
+ writeFileSync(
945
+ join(
946
+ tmp,
947
+ "node_modules",
948
+ "koishi-plugin-weird",
949
+ "package.json",
950
+ ),
951
+ JSON.stringify({
952
+ name: "koishi-plugin-weird",
953
+ version: "not.a.version",
954
+ }),
955
+ );
956
+ nextExitCode = 0;
957
+ // 经 install 注入清单声明(顺带刷新 Installer 的 manifest 快照)
958
+ const code = await app.installer.install({
959
+ "koishi-plugin-weird": "1.0.0",
960
+ });
961
+ expect(code).toBe(0);
962
+ const deps = await app.installer.getDeps();
963
+ expect(deps["koishi-plugin-weird"]?.resolved).toBe(
964
+ "not.a.version",
965
+ );
966
+ const replies = await client.receive(
967
+ "plugin.upgrade weird",
968
+ );
969
+ expect(replies[0]).toContain(
970
+ "所有插件已是最新版本。",
971
+ );
972
+ },
973
+ 20000,
974
+ );
677
975
  });