@koishi-ce/plugin-market 1.0.9 → 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,8 +2,20 @@
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";
@@ -35,24 +47,25 @@ const spawnBunMock = (_args: string[], _cwd: 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("../node/proc.ts", () => ({ spawnBun: spawnBunMock }));
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,59 +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
- // 触发了包管理器安装(bun install --registry …)
288
- expect(spawnCalls.length).toBe(1);
289
- expect(spawnCalls[0]?.[0]).toBe("install");
290
- // 重新读取临时 package.json:护栏项保持原样,新依赖加入
291
- const manifest = JSON.parse(
292
- await Bun.file(join(tmp, "package.json")).text(),
293
- ) as { dependencies: Record<string, string> };
294
- expect(manifest.dependencies["koishi"]).toBe("workspace:*");
295
- expect(manifest.dependencies["market-alias"]).toBe(
296
- "npm:@koishi-ce/anything@^1.0.0",
297
- );
298
- expect(manifest.dependencies["koishi-plugin-demo"]).toBe("^1.0.0");
299
- }, 15000);
300
-
301
- it("install 强制时无视本地满足也要装", async () => {
302
- nextExitCode = 0;
303
- spawnCalls.length = 0;
304
- const code = await app.installer.install(
305
- { "koishi-plugin-demo": "^1.0.0" },
306
- true,
307
- );
308
- expect(code).toBe(0);
309
- expect(spawnCalls.length).toBe(1);
310
- }, 15000);
311
-
312
- it("子进程非零退出码向上传递", async () => {
313
- nextExitCode = 1;
314
- spawnCalls.length = 0;
315
- const code = await app.installer.install(
316
- { "koishi-plugin-demo": "^2.0.0" },
317
- true,
318
- );
319
- expect(code).toBe(1);
320
- }, 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
+ );
321
454
 
322
- it("子进程 spawn 失败返回 -1", async () => {
455
+ itQuiet("子进程 spawn 失败返回 -1", async () => {
323
456
  nextSpawnError = true;
324
457
  nextExitCode = 0;
325
458
  const code = await app.installer.exec(["install"]);
@@ -327,12 +460,15 @@ describe("Installer 安装链路", () => {
327
460
  expect(code).toBe(-1);
328
461
  });
329
462
 
330
- it("exec 收集子进程 stdout / stderr 输出行", async () => {
331
- nextExitCode = 0;
332
- nextSpawnError = false;
333
- const code = await app.installer.exec(["install"]);
334
- expect(code).toBe(0);
335
- });
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
+ );
336
472
  });
337
473
 
338
474
  describe("isResidentInCache(装后重载判定)", () => {
@@ -342,9 +478,16 @@ describe("isResidentInCache(装后重载判定)", () => {
342
478
  mkdirSync(pkgDir, { recursive: true });
343
479
  writeFileSync(
344
480
  join(pkgDir, "package.json"),
345
- 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 = {}",
346
490
  );
347
- writeFileSync(join(pkgDir, "index.js"), "module.exports = {}");
348
491
  return pkgDir;
349
492
  }
350
493
 
@@ -352,16 +495,22 @@ describe("isResidentInCache(装后重载判定)", () => {
352
495
  const pkgDir = placePkg("koishi-plugin-resident-check");
353
496
  // 入口经 require 进入 require.cache,模拟旧版本驻留内存
354
497
  require(join(pkgDir, "index.js"));
355
- expect(isResidentInCache("koishi-plugin-resident-check")).toBe(true);
498
+ expect(
499
+ isResidentInCache("koishi-plugin-resident-check"),
500
+ ).toBe(true);
356
501
  });
357
502
 
358
503
  it("已安装但无模块驻留内存时返回 false", () => {
359
504
  placePkg("koishi-plugin-idle-check");
360
- expect(isResidentInCache("koishi-plugin-idle-check")).toBe(false);
505
+ expect(
506
+ isResidentInCache("koishi-plugin-idle-check"),
507
+ ).toBe(false);
361
508
  });
362
509
 
363
510
  it("包不存在时保守返回 true(宁可多重载不漏判)", () => {
364
- expect(isResidentInCache("koishi-plugin-absent-check")).toBe(true);
511
+ expect(
512
+ isResidentInCache("koishi-plugin-absent-check"),
513
+ ).toBe(true);
365
514
  });
366
515
  });
367
516
 
@@ -389,7 +538,9 @@ describe("registry 配置探测", () => {
389
538
  app3.plugin(http);
390
539
  app3.plugin(Installer, {});
391
540
  await app3.start();
392
- expect(app3.installer.endpoint).toBe("http://registry.example.npm/");
541
+ expect(app3.installer.endpoint).toBe(
542
+ "http://registry.example.npm/",
543
+ );
393
544
  await app3.stop();
394
545
  rmSync(join(tmp, ".npmrc"), { force: true });
395
546
  });
@@ -410,109 +561,178 @@ describe("MarketProvider 市场数据服务", () => {
410
561
  keywords: ["koishi", "plugin", "Tool"],
411
562
  },
412
563
  },
413
- { 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
+ },
414
570
  ],
415
571
  total: 1,
416
572
  };
417
573
  // start(true) 强制刷新市场数据(重新 collect)
418
574
  await svc?.start(true);
419
575
  // 等待节流窗口与逐包分析完成
420
- await new Promise((resolve) => setTimeout(resolve, 700));
576
+ await new Promise((resolve) =>
577
+ setTimeout(resolve, 700),
578
+ );
421
579
  const payload = await svc?.get();
422
580
  expect(payload).toBeDefined();
423
581
  // 非 plugin 条目被剔除,只保留 demo
424
- expect(Object.keys(payload?.data ?? {})).toEqual(["koishi-plugin-demo"]);
582
+ expect(Object.keys(payload?.data ?? {})).toEqual([
583
+ "koishi-plugin-demo",
584
+ ]);
425
585
  expect(payload?.total).toBe(1);
426
586
  expect(payload?.failed).toBe(0);
427
- 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
+ );
428
590
  });
429
591
 
430
592
  it("依赖 / 注册表数据服务读取安装器缓存", async () => {
431
- const dependencies = await app.get("console.services.dependencies")?.get();
432
- expect(dependencies?.["koishi-plugin-demo"]?.request).toBeTruthy();
433
- 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");
434
602
 
435
- const registry = await app.get("console.services.registry")?.get();
436
- expect(Object.keys(registry?.["koishi-plugin-demo"] ?? {})).toContain(
437
- "2.0.0",
438
- );
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");
439
609
  });
440
610
 
441
- it("搜索接口失败时 get 返回空数据与错误标记", async () => {
442
- const svc = app.get("console.services.market");
443
- searchResponse = null;
444
- // 强制重扫:collect 失败置 _error,get 返回空 payload
445
- await svc?.start(true);
446
- const payload = await svc?.get();
447
- expect(payload).toEqual({ data: {}, failed: 0, total: 0, progress: 0 });
448
- searchResponse = {
449
- objects: [],
450
- total: 0,
451
- };
452
- });
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
+ );
453
631
 
454
632
  it("控制台连接事件在数据过期时触发刷新", async () => {
455
633
  const svc = app.get("console.services.market");
456
634
  expect(svc).toBeDefined();
457
635
  // 伪造一个在线客户端,使连接事件通过在线检查(broadcast 需可用的 socket)
458
- const fakeClient = { id: "conn-1", socket: { send() {} } };
459
- (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;
460
643
  // 刚刷新过:12 小时窗口内直接返回,不重新收集
461
- const timestamp = svc?.["_timestamp" as keyof typeof svc] as number;
644
+ const timestamp = svc?.[
645
+ "_timestamp" as keyof typeof svc
646
+ ] as number;
462
647
  // console/connection 载荷声明为 Client,桩对象仅含在线检查所需的最小面,类型层断言穿透
463
648
  app.emit("console/connection", fakeClient as never);
464
- 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);
465
652
  // 将时间戳回拨到窗口外,连接事件重新触发 start(异步监听,稍等)
466
653
  (svc as Record<string, unknown>)["_timestamp"] = 0;
467
654
  app.emit("console/connection", fakeClient as never);
468
655
  await new Promise((resolve) => setTimeout(resolve, 20));
469
656
  expect(
470
- (svc as Record<string, unknown>)["_timestamp"] as number,
657
+ (svc as Record<string, unknown>)[
658
+ "_timestamp"
659
+ ] as number,
471
660
  ).toBeGreaterThan(0);
472
661
  delete app.console.clients["conn-1"];
473
662
  });
474
663
  });
475
664
 
476
665
  describe("market 聊天指令", () => {
477
- it("plugin.install 缺参与未找到的报错路径", async () => {
478
- const missing = await client.receive("plugin.install");
479
- expect(missing[0]).toContain("请输入插件名。");
480
- const notFound = await client.receive("plugin.install absent-pkg");
481
- expect(notFound[0]).toContain("未找到该插件。");
482
- });
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
+ );
483
679
 
484
680
  it("plugin.install 已安装时提示重复", async () => {
485
- const replies = await client.receive("plugin.install demo");
681
+ const replies = await client.receive(
682
+ "plugin.install demo",
683
+ );
486
684
  expect(replies[0]).toContain("该插件已安装。");
487
685
  });
488
686
 
489
- it("plugin.install 安装新插件并写入依赖", async () => {
490
- nextExitCode = 0;
491
- spawnCalls.length = 0;
492
- const replies = await client.receive("plugin.install newpkg");
493
- expect(replies[0]).toContain("安装成功!");
494
- expect(spawnCalls.length).toBe(1);
495
- const manifest = JSON.parse(
496
- await Bun.file(join(tmp, "package.json")).text(),
497
- ) as { dependencies: Record<string, string> };
498
- expect(manifest.dependencies["koishi-plugin-newpkg"]).toBe("1.0.0");
499
- // 重启消息在安装完成后复位(Loader 与桩形状不同,经 unknown 二段式断言)
500
- expect((app.loader as unknown as FakeLoader).envData["message"]).toBeNull();
501
- }, 15000);
502
-
503
- it("plugin.uninstall 卸载依赖并从清单移除", async () => {
504
- nextExitCode = 0;
505
- spawnCalls.length = 0;
506
- const replies = await client.receive("plugin.uninstall newpkg");
507
- expect(replies[0]).toContain("卸载成功!");
508
- const manifest = JSON.parse(
509
- await Bun.file(join(tmp, "package.json")).text(),
510
- ) as { dependencies: Record<string, string> };
511
- expect(manifest.dependencies["koishi-plugin-newpkg"]).toBeUndefined();
512
- }, 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
+ );
513
731
 
514
732
  it("plugin.uninstall 未安装时提示", async () => {
515
- const replies = await client.receive("plugin.uninstall absent-pkg");
733
+ const replies = await client.receive(
734
+ "plugin.uninstall absent-pkg",
735
+ );
516
736
  expect(replies[0]).toContain("该插件未安装。");
517
737
  });
518
738
 
@@ -523,48 +743,73 @@ describe("market 聊天指令", () => {
523
743
  });
524
744
 
525
745
  describe("market 进阶链路", () => {
526
- it("宿主配置不可写时不加载安装器", async () => {
527
- const appNoLoader = new App();
528
- appNoLoader.plugin(http);
529
- appNoLoader.plugin(FakeConsole as unknown as Plugin.Constructor<TestApp>);
530
- appNoLoader.plugin(market, {
531
- registry: { endpoint: `http://127.0.0.1:${registryServer.port}/` },
532
- });
533
- await appNoLoader.start();
534
- // apply loader 缺席时仅告警并提前返回
535
- expect(appNoLoader.installer).toBeUndefined();
536
- 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
+ }
537
768
  });
538
769
 
539
- it("浏览器 market/install 监听器执行安装并刷新服务", async () => {
540
- nextExitCode = 0;
541
- const listener = app.console.listeners["market/install"];
542
- expect(listener).toBeDefined();
543
- const code = (await listener?.callback.call(
544
- {} as never,
545
- { "koishi-plugin-newpkg": "1.0.0" },
546
- true,
547
- )) as number;
548
- expect(code).toBe(0);
549
- }, 15000);
550
-
551
- it("浏览器 market/registry 监听器批量查询包元数据", async () => {
552
- const listener = app.console.listeners["market/registry"];
553
- expect(listener).toBeDefined();
554
- const meta = (await listener?.callback.call({} as never, [
555
- "koishi-plugin-demo",
556
- "koishi-plugin-missing",
557
- ])) as Record<string, unknown>;
558
- expect(Object.keys(meta["koishi-plugin-demo"] ?? {})).toContain("2.0.0");
559
- expect(meta["koishi-plugin-missing"]).toEqual({});
560
- });
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
+ );
561
803
 
562
804
  it("搜索结果中不兼容的包被跳过(analyze onSkipped/ignored)", async () => {
563
805
  const svc = app.get("console.services.market");
564
806
  // ghost 有 registry 条目,但版本声明的 koishi peer 与 4.x 不相交
565
807
  registryData["koishi-plugin-ghost"] = {
566
808
  versions: {
567
- "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
+ },
568
813
  },
569
814
  time: { "1.0.0": "2024-01-01T00:00:00Z" },
570
815
  };
@@ -582,7 +827,9 @@ describe("market 进阶链路", () => {
582
827
  };
583
828
  await svc?.start(true);
584
829
  // collect 对 analyze 为即发即忘,等待逐包分析完成
585
- await new Promise((resolve) => setTimeout(resolve, 300));
830
+ await new Promise((resolve) =>
831
+ setTimeout(resolve, 300),
832
+ );
586
833
  const payload = await svc?.get();
587
834
  // 无兼容版本:对象标记 ignored,不进入数据缓存。
588
835
  // progress 恒为 0:Scanner 以 defineProperty 定义 progress(不可写),
@@ -594,83 +841,135 @@ describe("market 进阶链路", () => {
594
841
  delete registryData["koishi-plugin-ghost"];
595
842
  });
596
843
 
597
- it("搜索结果中被限流的包经重试后计入 failed(onFailure)", async () => {
598
- const svc = app.get("console.services.market");
599
- searchResponse = {
600
- 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"),
601
886
  {
602
- package: {
603
- name: "koishi-plugin-ratelimited",
604
- version: "1.0.0",
605
- 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" },
606
935
  },
607
936
  },
608
- ],
609
- total: 1,
610
- };
611
- await svc?.start(true);
612
- // 等待限流重试(Retry-After 10ms × 3 次)与即发即忘的 analyze。
613
- // 注意不能经 get() 断言:super.start() 会清空 _task,get() 触发的
614
- // 二次 collect 会把 failed 重置(即发即忘的 analyze 尚未完成)。
615
- await new Promise((resolve) => setTimeout(resolve, 400));
616
- // 不可达/被限流的包名进入 failed 列表(上一个用例中 registry
617
- // 条目已删除的 ghost 包经 404 路径同样落入此处)
618
- const provider = svc as unknown as { failed: string[] };
619
- expect(
620
- provider.failed.some((name) => name.startsWith("koishi-plugin-")),
621
- ).toBe(true);
622
- });
623
-
624
- it("plugin.upgrade 检出可升级项并输出确认提示", async () => {
625
- // 本地放置旧版安装,使 resolved 有值且低于远端 latest
626
- mkdirSync(join(tmp, "node_modules", "koishi-plugin-demo"), {
627
- recursive: true,
628
- });
629
- writeFileSync(
630
- join(tmp, "node_modules", "koishi-plugin-demo", "package.json"),
631
- JSON.stringify({ name: "koishi-plugin-demo", version: "1.0.0" }),
632
- );
633
- app.installer.refresh();
634
-
635
- nextExitCode = 0;
636
- // 发出升级指令(异步等待确认),再以 Y 回复确认。
637
- // 注:mock 环境下指令 ctx 对 loader 服务的可见性受 cordis
638
- // isolate 语义限制(见仓库测试任务记录),确认后的安装段
639
- // 由 market/install 监听器用例覆盖。
640
- const question = client.receive("plugin.upgrade demo");
641
- await new Promise((resolve) => setTimeout(resolve, 200));
642
- await client.receive("Y");
643
- const replies = await question;
644
- const output = replies.join("\n");
645
- expect(output).toContain("koishi-plugin-demo");
646
- expect(output).toContain("1.0.0 -> 2.0.0");
647
- }, 20000);
648
-
649
- it("plugin.upgrade 对本地畸形版本静默跳过(非法 semver catch)", async () => {
650
- // registry 侧提供合法 latest;本地安装产物的 version 是畸形串,
651
- // request(清单声明)合法故不标记 invalid,gt(latest, resolved)
652
- // 解析失败进入 catch 分支:该包被过滤,视为无可升级项
653
- registryData["koishi-plugin-weird"] = {
654
- versions: {
655
- "2.0.0": { version: "2.0.0", peerDependencies: { koishi: "^4.17.0" } },
656
- },
657
- };
658
- mkdirSync(join(tmp, "node_modules", "koishi-plugin-weird"), {
659
- recursive: true,
660
- });
661
- writeFileSync(
662
- join(tmp, "node_modules", "koishi-plugin-weird", "package.json"),
663
- JSON.stringify({ name: "koishi-plugin-weird", version: "not.a.version" }),
664
- );
665
- nextExitCode = 0;
666
- // 经 install 注入清单声明(顺带刷新 Installer 的 manifest 快照)
667
- const code = await app.installer.install({
668
- "koishi-plugin-weird": "1.0.0",
669
- });
670
- expect(code).toBe(0);
671
- const deps = await app.installer.getDeps();
672
- expect(deps["koishi-plugin-weird"]?.resolved).toBe("not.a.version");
673
- const replies = await client.receive("plugin.upgrade weird");
674
- expect(replies[0]).toContain("所有插件已是最新版本。");
675
- }, 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
+ );
676
975
  });