@koishi-ce/plugin-config 1.0.7 → 1.0.9

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.
@@ -11,13 +11,32 @@
11
11
  * 与配置树的增删改、PackageProvider 的运行时解析缓存与按需刷新链路。
12
12
  */
13
13
 
14
- import { afterAll, beforeAll, describe, expect, it } from "bun:test";
14
+ import {
15
+ afterAll,
16
+ beforeAll,
17
+ describe,
18
+ expect,
19
+ it,
20
+ } from "bun:test";
21
+ import {
22
+ mkdirSync,
23
+ mkdtempSync,
24
+ rmSync,
25
+ writeFileSync,
26
+ } from "node:fs";
15
27
  import type { IncomingMessage } from "node:http";
16
- import { type Client, Console, type Entry } from "@koishi-ce/console";
28
+ import { tmpdir } from "node:os";
29
+ import { join } from "node:path";
30
+ import {
31
+ type Client,
32
+ Console,
33
+ type Entry,
34
+ } from "@koishi-ce/console";
17
35
  import {
18
36
  App,
19
37
  type Context,
20
38
  type Dict,
39
+ Logger,
21
40
  type Plugin,
22
41
  type Universal,
23
42
  } from "@koishi-ce/koishi";
@@ -28,28 +47,44 @@ import * as configPlugin from "@koishi-ce/plugin-config";
28
47
  /** 出站消息形状 */
29
48
  interface SentMessage {
30
49
  type: string;
31
- body: { id?: number; key?: string; value?: unknown; error?: string };
50
+ body: {
51
+ id?: number;
52
+ key?: string;
53
+ value?: unknown;
54
+ error?: string;
55
+ };
32
56
  }
33
57
 
34
58
  /** 内存 WebSocket 桩 */
35
59
  class FakeSocket {
36
60
  sent: string[] = [];
37
61
  // message 与 close 的监听器统一为同构签名(never 载荷),保证集合存取类型一致
38
- private messageHandlers = new Set<(event: never) => void>();
62
+ private messageHandlers = new Set<
63
+ (event: never) => void
64
+ >();
39
65
  private closeHandlers = new Set<(event: never) => void>();
40
66
 
41
67
  send(data: string) {
42
68
  this.sent.push(data);
43
69
  }
44
70
 
45
- addEventListener(type: string, listener: (event: never) => void) {
46
- if (type === "message") this.messageHandlers.add(listener);
71
+ addEventListener(
72
+ type: string,
73
+ listener: (event: never) => void,
74
+ ) {
75
+ if (type === "message")
76
+ this.messageHandlers.add(listener);
47
77
  if (type === "close") this.closeHandlers.add(listener);
48
78
  }
49
79
 
50
- removeEventListener(type: string, listener: (event: never) => void) {
51
- if (type === "message") this.messageHandlers.delete(listener);
52
- if (type === "close") this.closeHandlers.delete(listener);
80
+ removeEventListener(
81
+ type: string,
82
+ listener: (event: never) => void,
83
+ ) {
84
+ if (type === "message")
85
+ this.messageHandlers.delete(listener);
86
+ if (type === "close")
87
+ this.closeHandlers.delete(listener);
53
88
  }
54
89
 
55
90
  get socket(): Universal.WebSocket {
@@ -68,19 +103,49 @@ function tick(ms = 20) {
68
103
  return new Promise((resolve) => setTimeout(resolve, ms));
69
104
  }
70
105
 
106
+ /** 声明预期触发告警(失败路径正是被测行为)的用例:执行期间静默指定日志域。 */
107
+ const itQuiet = (
108
+ domains: string[],
109
+ name: string,
110
+ fn: () => Promise<void> | void,
111
+ ) =>
112
+ it(name, async () => {
113
+ const levels = Logger.levels as Record<string, number>;
114
+ const saved = domains.map(
115
+ (d) => [d, levels[d]] as const,
116
+ );
117
+ for (const d of domains) levels[d] = 0;
118
+ try {
119
+ await fn();
120
+ } finally {
121
+ for (const [d, v] of saved) {
122
+ if (v === undefined) delete levels[d];
123
+ else levels[d] = v;
124
+ }
125
+ }
126
+ });
127
+
71
128
  /** Console 抽象基类的最小实现 */
72
129
  class TestConsole extends Console {
73
130
  resolveEntry(files: Entry.Files, key: string): string[] {
74
131
  const list =
75
- typeof files === "string" || Array.isArray(files) ? files : files.prod;
132
+ typeof files === "string" || Array.isArray(files)
133
+ ? files
134
+ : files.prod;
76
135
  return [String(list), key];
77
136
  }
78
137
 
79
- acceptClient(socket: Universal.WebSocket, request: IncomingMessage): Client {
138
+ acceptClient(
139
+ socket: Universal.WebSocket,
140
+ request: IncomingMessage,
141
+ ): Client {
80
142
  let accepted: Client | undefined;
81
- const dispose = this.ctx.on("console/connection", (client) => {
82
- accepted = client;
83
- });
143
+ const dispose = this.ctx.on(
144
+ "console/connection",
145
+ (client) => {
146
+ accepted = client;
147
+ },
148
+ );
84
149
  this.accept(socket, request);
85
150
  dispose();
86
151
  if (!accepted) throw new Error("client not accepted");
@@ -93,6 +158,8 @@ class TestLoader extends Loader {
93
158
  data: Dict<unknown> = Object.create(null);
94
159
  writes: { filename: string; config: unknown }[] = [];
95
160
  fullReloadCount = 0;
161
+ /** 每个名字被 import 的次数(断言失败缓存不再重复解析) */
162
+ importCounts: Dict<number> = Object.create(null);
96
163
 
97
164
  constructor() {
98
165
  super();
@@ -100,6 +167,8 @@ class TestLoader extends Loader {
100
167
  }
101
168
 
102
169
  override async import(name: string) {
170
+ this.importCounts[name] =
171
+ (this.importCounts[name] ?? 0) + 1;
103
172
  if (name === "bad-plugin") {
104
173
  throw new Error("cannot resolve bad-plugin");
105
174
  }
@@ -116,11 +185,15 @@ class TestLoader extends Loader {
116
185
  }
117
186
 
118
187
  protected override locateConfig(): Promise<never> {
119
- throw new Error("test loader does not touch the file system");
188
+ throw new Error(
189
+ "test loader does not touch the file system",
190
+ );
120
191
  }
121
192
 
122
193
  protected override parseConfig(): Promise<never> {
123
- throw new Error("test loader does not touch the file system");
194
+ throw new Error(
195
+ "test loader does not touch the file system",
196
+ );
124
197
  }
125
198
 
126
199
  protected override async saveConfig(
@@ -132,6 +205,68 @@ class TestLoader extends Loader {
132
205
  }
133
206
 
134
207
  const loader = new TestLoader();
208
+
209
+ // loader 的 apply/reload/unload 与启动横幅是正常生命周期 info(且部分在下方顶层
210
+ // createApp 期间即产生),收敛为仅错误级;恢复见文件末 afterAll
211
+ const testLevels = Logger.levels as Record<string, number>;
212
+ testLevels["loader"] = 1;
213
+ testLevels["app"] = 1;
214
+
215
+ /**
216
+ * Hermetic 应用根:本机包扫描(LocalScanner)与 workspace 路径键解析均以
217
+ * loader.baseDir 为锚,固定到临时 fixture,避免断言依赖真实仓库 node_modules
218
+ * 的链接状态(哪些 workspace 包被链入由包管理器布局决定,换环境即挂)。
219
+ */
220
+ const fixtureRoot = mkdtempSync(
221
+ join(tmpdir(), "koishi-config-pkg-"),
222
+ );
223
+ mkdirSync(
224
+ join(
225
+ fixtureRoot,
226
+ "node_modules/@koishi-ce/plugin-fixture",
227
+ ),
228
+ {
229
+ recursive: true,
230
+ },
231
+ );
232
+ writeFileSync(
233
+ join(
234
+ fixtureRoot,
235
+ "node_modules/@koishi-ce/plugin-fixture/package.json",
236
+ ),
237
+ JSON.stringify(
238
+ {
239
+ name: "@koishi-ce/plugin-fixture",
240
+ version: "1.0.0",
241
+ peerDependencies: { koishi: "^4.18.0" },
242
+ },
243
+ null,
244
+ "\t",
245
+ ),
246
+ );
247
+ mkdirSync(join(fixtureRoot, "plugins/webui/auth"), {
248
+ recursive: true,
249
+ });
250
+ writeFileSync(
251
+ join(fixtureRoot, "plugins/webui/auth/package.json"),
252
+ JSON.stringify({
253
+ name: "@koishi-ce/plugin-auth",
254
+ version: "1.0.0",
255
+ }),
256
+ );
257
+ // 配置树里的路径键 ./missing-pkg 也应真实存在,否则包扫描每轮都刷 ENOENT 告警
258
+ mkdirSync(join(fixtureRoot, "missing-pkg"), {
259
+ recursive: true,
260
+ });
261
+ writeFileSync(
262
+ join(fixtureRoot, "missing-pkg/package.json"),
263
+ JSON.stringify({
264
+ name: "koishi-plugin-missing-pkg",
265
+ version: "1.0.0",
266
+ }),
267
+ );
268
+ loader.baseDir = fixtureRoot;
269
+
135
270
  loader.config = {
136
271
  plugins: {
137
272
  $sfolded: true,
@@ -150,17 +285,22 @@ loader.config = {
150
285
 
151
286
  const app = await loader.createApp();
152
287
  // Console 基类的 static inject 是 cordis 3 旧形态,与 Plugin.Constructor 期待类型不兼容,仅做类型层转型
153
- app.plugin(TestConsole as unknown as Plugin.Constructor<App>);
288
+ app.plugin(
289
+ TestConsole as unknown as Plugin.Constructor<App>,
290
+ );
154
291
  app.plugin(configPlugin);
155
292
 
156
293
  const service = () => app.console as TestConsole;
157
- const writer = () => app.get("console.services.config") as ConfigWriter;
294
+ const writer = () =>
295
+ app.get("console.services.config") as ConfigWriter;
158
296
  let socket: FakeSocket;
159
297
  let client: Client;
160
298
 
161
299
  /** 读取客户端已收到的全部消息 */
162
300
  function readSent(): SentMessage[] {
163
- return socket.sent.map((line) => JSON.parse(line) as SentMessage);
301
+ return socket.sent.map(
302
+ (line) => JSON.parse(line) as SentMessage,
303
+ );
164
304
  }
165
305
 
166
306
  /** 等待写盘队列(合并窗口 setTimeout 0)落定 */
@@ -171,7 +311,10 @@ async function flushWrites() {
171
311
  beforeAll(async () => {
172
312
  await app.start();
173
313
  socket = new FakeSocket();
174
- client = service().acceptClient(socket.socket, fakeRequest());
314
+ client = service().acceptClient(
315
+ socket.socket,
316
+ fakeRequest(),
317
+ );
175
318
  await tick();
176
319
  socket.sent.length = 0;
177
320
  });
@@ -182,30 +325,53 @@ afterAll(async () => {
182
325
  }
183
326
  await tick();
184
327
  await app.stop();
328
+ rmSync(fixtureRoot, { recursive: true, force: true });
329
+ // 恢复域级阈值,避免同进程后续测试文件被连带静默
330
+ delete testLevels["loader"];
331
+ delete testLevels["app"];
185
332
  });
186
333
 
187
334
  describe("@koishi-ce/plugin-config", () => {
188
- it("无可写 loader 时仅告警并跳过装配", async () => {
189
- const bare = new App();
190
- bare.plugin(TestConsole as unknown as Plugin.Constructor<App>);
191
- bare.plugin(configPlugin);
192
- await bare.start();
193
- expect(bare.get("console.services.config")).toBeUndefined();
194
- expect(bare.get("console.services.packages")).toBeUndefined();
195
- expect(bare.get("console.services.services")).toBeUndefined();
196
- await bare.stop();
197
- });
335
+ itQuiet(
336
+ ["app"],
337
+ "无可写 loader 时仅告警并跳过装配",
338
+ async () => {
339
+ const bare = new App();
340
+ bare.plugin(
341
+ TestConsole as unknown as Plugin.Constructor<App>,
342
+ );
343
+ bare.plugin(configPlugin);
344
+ await bare.start();
345
+ expect(
346
+ bare.get("console.services.config"),
347
+ ).toBeUndefined();
348
+ expect(
349
+ bare.get("console.services.packages"),
350
+ ).toBeUndefined();
351
+ expect(
352
+ bare.get("console.services.services"),
353
+ ).toBeUndefined();
354
+ await bare.stop();
355
+ },
356
+ );
198
357
 
199
358
  it("挂载 packages / services / config 三个数据服务", () => {
200
- expect(app.get("console.services.packages")).toBeTruthy();
201
- expect(app.get("console.services.services")).toBeTruthy();
359
+ expect(
360
+ app.get("console.services.packages"),
361
+ ).toBeTruthy();
362
+ expect(
363
+ app.get("console.services.services"),
364
+ ).toBeTruthy();
202
365
  expect(app.get("console.services.config")).toBeTruthy();
203
366
  });
204
367
 
205
368
  describe("ConfigWriter.get()", () => {
206
369
  it("过滤未加载项,分组递归展开,$ 与 ~ 键原样保留", async () => {
207
370
  const result = await writer().get();
208
- const plugins = result.plugins as Record<string, unknown>;
371
+ const plugins = result.plugins as Record<
372
+ string,
373
+ unknown
374
+ >;
209
375
  // $if 为假的键不出现;$ 内部键与 ~ 停用键保留
210
376
  expect("gone:xyz" in plugins).toBe(false);
211
377
  expect(plugins["$sfolded"]).toBe(true);
@@ -222,7 +388,9 @@ describe("@koishi-ce/plugin-config", () => {
222
388
 
223
389
  describe("PackageProvider", () => {
224
390
  it("收集本机包与 workspace 源码包并附全局设置条目", async () => {
225
- const provider = app.get("console.services.packages") as unknown as {
391
+ const provider = app.get(
392
+ "console.services.packages",
393
+ ) as unknown as {
226
394
  get(): Promise<Dict<Record<string, unknown>>>;
227
395
  };
228
396
  const data = await provider.get();
@@ -230,30 +398,68 @@ describe("@koishi-ce/plugin-config", () => {
230
398
  expect(data[""]).toBeTruthy();
231
399
  // workspace 路径键引用的源码包:带 paths 与运行时缓存
232
400
  const auth = data["@koishi-ce/plugin-auth"];
233
- expect(auth?.["paths"]).toEqual(["./plugins/webui/auth"]);
401
+ expect(auth?.["paths"]).toEqual([
402
+ "./plugins/webui/auth",
403
+ ]);
234
404
  expect(auth?.["runtime"]).toBeTruthy();
235
- // 本机 node_modules 扫描出的已装插件包
236
- expect(data["@koishi-ce/plugin-server"]).toBeTruthy();
405
+ // 本机 node_modules 扫描出的已装插件包(fixture 预置,见文件头说明)
406
+ expect(
407
+ data["@koishi-ce/plugin-fixture"],
408
+ ).toBeTruthy();
237
409
  });
238
410
 
239
- it("request-runtime 按路径键 / 短名解析并刷新,解析失败不缓存", async () => {
240
- const listener = app.console.listeners["config/request-runtime"];
241
- expect(listener).toBeTruthy();
242
- // 失败路径:stub 的 bad-plugin 抛错,返回 { failed: true } 不入缓存
243
- await listener?.callback.call(client as never, "bad-plugin");
244
- await flushWrites();
245
- // 成功路径:workspace 包名命中 pathKeys
246
- await listener?.callback.call(client as never, "@koishi-ce/plugin-auth");
247
- await flushWrites();
248
- const provider = app.get("console.services.packages") as unknown as {
249
- cache: Dict<unknown>;
250
- pathKeys: Dict<string>;
251
- };
252
- expect(provider.pathKeys["@koishi-ce/plugin-auth"]).toBe(
253
- "./plugins/webui/auth",
254
- );
255
- expect(provider.cache["./plugins/webui/auth"]).toBeTruthy();
256
- });
411
+ itQuiet(
412
+ ["config"],
413
+ "request-runtime 按路径键 / 短名解析并刷新,失败结果同样缓存",
414
+ async () => {
415
+ const listener =
416
+ app.console.listeners["config/request-runtime"];
417
+ expect(listener).toBeTruthy();
418
+ const provider = app.get(
419
+ "console.services.packages",
420
+ ) as unknown as {
421
+ cache: Dict<{ failed?: boolean }>;
422
+ pathKeys: Dict<string>;
423
+ };
424
+ // 失败路径:stub 的 bad-plugin 抛错,{ failed: true } 入缓存并
425
+ // 随数据下发——前端据以展示失败提示并停止重发请求
426
+ await listener?.callback.call(
427
+ client as never,
428
+ "bad-plugin",
429
+ );
430
+ await flushWrites();
431
+ expect(provider.cache["bad-plugin"]).toEqual({
432
+ failed: true,
433
+ });
434
+ // 重复请求命中失败缓存,不再触发 loader.import(防活锁刷屏)
435
+ const importsAfterFirst =
436
+ loader.importCounts["bad-plugin"] ?? 0;
437
+ expect(importsAfterFirst).toBeGreaterThan(0);
438
+ await listener?.callback.call(
439
+ client as never,
440
+ "bad-plugin",
441
+ );
442
+ await flushWrites();
443
+ expect(loader.importCounts["bad-plugin"] ?? 0).toBe(
444
+ importsAfterFirst,
445
+ );
446
+ // 成功路径:workspace 包名命中 pathKeys
447
+ await listener?.callback.call(
448
+ client as never,
449
+ "@koishi-ce/plugin-auth",
450
+ );
451
+ await flushWrites();
452
+ expect(
453
+ provider.pathKeys["@koishi-ce/plugin-auth"],
454
+ ).toBe("./plugins/webui/auth");
455
+ expect(
456
+ provider.cache["./plugins/webui/auth"],
457
+ ).toBeTruthy();
458
+ expect(
459
+ provider.cache["./plugins/webui/auth"]?.failed,
460
+ ).toBeUndefined();
461
+ },
462
+ );
257
463
 
258
464
  it("internal/runtime / fork / status 与 hmr/reload 触发运行时更新", async () => {
259
465
  const dummy = loader.data["keep"] as Plugin;
@@ -270,7 +476,11 @@ describe("@koishi-ce/plugin-config", () => {
270
476
  app.emit("internal/fork", fork as never);
271
477
  // internal/status 签名为 (scope, oldValue) 双参,仓库内监听者均不消费第二参,
272
478
  // 补 undefined 占位满足调用形状且不改变运行时行为
273
- app.emit("internal/status", fork as never, undefined as never);
479
+ app.emit(
480
+ "internal/status",
481
+ fork as never,
482
+ undefined as never,
483
+ );
274
484
  app.emit("hmr/reload", [[dummy]] as never);
275
485
  await tick(30);
276
486
  expect(true).toBe(true);
@@ -279,17 +489,24 @@ describe("@koishi-ce/plugin-config", () => {
279
489
 
280
490
  describe("ServiceProvider", () => {
281
491
  it("上报各服务的提供者上下文并在服务变动时刷新", async () => {
282
- const provider = app.get("console.services.services") as unknown as {
492
+ const provider = app.get(
493
+ "console.services.services",
494
+ ) as unknown as {
283
495
  get(): Promise<Dict<number>>;
284
496
  };
285
497
  const data = await provider.get();
286
498
  expect(data["console"]).toBeGreaterThanOrEqual(0);
287
499
  // 服务注册(set 会广播 internal/service)→ refresh → 客户端收到 services 数据
288
500
  socket.sent.length = 0;
289
- app.set("probe.svc" as never, { marker: true } as never);
501
+ app.set(
502
+ "probe.svc" as never,
503
+ { marker: true } as never,
504
+ );
290
505
  await tick(30);
291
506
  const messages = readSent().filter(
292
- (msg) => msg.type === "data" && msg.body.key === "services",
507
+ (msg) =>
508
+ msg.type === "data" &&
509
+ msg.body.key === "services",
293
510
  );
294
511
  expect(messages.length).toBeGreaterThan(0);
295
512
  });
@@ -297,24 +514,37 @@ describe("@koishi-ce/plugin-config", () => {
297
514
 
298
515
  describe("ConfigWriter 事件", () => {
299
516
  it("manager/meta 更新元数据键(含 null 删除)", async () => {
300
- const listener = app.console.listeners["manager/meta"];
301
- await listener?.callback.call(client as never, "abc", {
302
- $label: "L",
303
- $collapsed: null,
304
- });
517
+ const listener =
518
+ app.console.listeners["manager/meta"];
519
+ await listener?.callback.call(
520
+ client as never,
521
+ "abc",
522
+ {
523
+ $label: "L",
524
+ $collapsed: null,
525
+ },
526
+ );
305
527
  await flushWrites();
306
528
  const plugins = loader.config.plugins as Record<
307
529
  string,
308
530
  Record<string, unknown>
309
531
  >;
310
532
  expect(plugins["keep:abc"]?.["$label"]).toBe("L");
311
- expect("$collapsed" in (plugins["keep:abc"] ?? {})).toBe(false);
533
+ expect(
534
+ "$collapsed" in (plugins["keep:abc"] ?? {}),
535
+ ).toBe(false);
312
536
  // 元数据键置于配置开头
313
- expect(Object.keys(plugins["keep:abc"] ?? {})[0]).toBe("$label");
537
+ expect(
538
+ Object.keys(plugins["keep:abc"] ?? {})[0],
539
+ ).toBe("$label");
314
540
  // 分组内插件的 meta(按 ident 递归定位)
315
- await listener?.callback.call(client as never, "two", {
316
- $label: "G",
317
- });
541
+ await listener?.callback.call(
542
+ client as never,
543
+ "two",
544
+ {
545
+ $label: "G",
546
+ },
547
+ );
318
548
  await flushWrites();
319
549
  const group = plugins["group:g1"] as Record<
320
550
  string,
@@ -324,85 +554,164 @@ describe("@koishi-ce/plugin-config", () => {
324
554
  });
325
555
 
326
556
  it("manager/reload 更新插件配置并保持键位置", async () => {
327
- const listener = app.console.listeners["manager/reload"];
328
- await listener?.callback.call(client as never, "", "keep:abc", {
329
- v: 2,
330
- });
557
+ const listener =
558
+ app.console.listeners["manager/reload"];
559
+ await listener?.callback.call(
560
+ client as never,
561
+ "",
562
+ "keep:abc",
563
+ {
564
+ v: 2,
565
+ },
566
+ );
331
567
  await flushWrites();
332
- const plugins = loader.config.plugins as Record<string, unknown>;
568
+ const plugins = loader.config.plugins as Record<
569
+ string,
570
+ unknown
571
+ >;
333
572
  expect(plugins["keep:abc"]).toEqual({ v: 2 });
334
573
  const dummy = loader.data["keep"] as Plugin;
335
- expect(app.registry.get(dummy)?.config).toEqual({ v: 2 });
574
+ expect(app.registry.get(dummy)?.config).toEqual({
575
+ v: 2,
576
+ });
336
577
  // 运行期更新触发回写
337
578
  expect(loader.writes.length).toBeGreaterThan(0);
338
579
  });
339
580
 
340
581
  it("manager/unload 停用插件(原位改 ~ 与按位置插入)", async () => {
341
- const listener = app.console.listeners["manager/unload"];
582
+ const listener =
583
+ app.console.listeners["manager/unload"];
342
584
  // 原位重命名
343
- await listener?.callback.call(client as never, "", "keep:abc", {
344
- v: 3,
345
- });
585
+ await listener?.callback.call(
586
+ client as never,
587
+ "",
588
+ "keep:abc",
589
+ {
590
+ v: 3,
591
+ },
592
+ );
346
593
  await flushWrites();
347
- const plugins = loader.config.plugins as Record<string, unknown>;
594
+ const plugins = loader.config.plugins as Record<
595
+ string,
596
+ unknown
597
+ >;
348
598
  expect("keep:abc" in plugins).toBe(false);
349
599
  expect(plugins["~keep:abc"]).toEqual({ v: 3 });
350
600
  const dummy = loader.data["keep"] as Plugin;
351
601
  expect(app.registry.get(dummy)).toBeUndefined();
352
602
 
353
603
  // 先补一个可停用键,再按 index 插入到指定位置
354
- const reload = app.console.listeners["manager/reload"];
355
- await reload?.callback.call(client as never, "", "back:two", {
356
- b: 1,
357
- });
604
+ const reload =
605
+ app.console.listeners["manager/reload"];
606
+ await reload?.callback.call(
607
+ client as never,
608
+ "",
609
+ "back:two",
610
+ {
611
+ b: 1,
612
+ },
613
+ );
358
614
  await flushWrites();
359
- await listener?.callback.call(client as never, "", "back:two", {}, 1);
615
+ await listener?.callback.call(
616
+ client as never,
617
+ "",
618
+ "back:two",
619
+ {},
620
+ 1,
621
+ );
360
622
  await flushWrites();
361
- const keys = Object.keys(loader.config.plugins as object);
623
+ const keys = Object.keys(
624
+ loader.config.plugins as object,
625
+ );
362
626
  expect(keys.indexOf("~back:two")).toBe(1);
363
627
  });
364
628
 
365
629
  it("manager/remove 彻底移除插件(含 ~ 停用态键)", async () => {
366
- const listener = app.console.listeners["manager/remove"];
367
- await listener?.callback.call(client as never, "", "~keep:abc");
630
+ const listener =
631
+ app.console.listeners["manager/remove"];
632
+ await listener?.callback.call(
633
+ client as never,
634
+ "",
635
+ "~keep:abc",
636
+ );
368
637
  await flushWrites();
369
- const plugins = loader.config.plugins as Record<string, unknown>;
638
+ const plugins = loader.config.plugins as Record<
639
+ string,
640
+ unknown
641
+ >;
370
642
  expect("keep:abc" in plugins).toBe(false);
371
643
  expect("~keep:abc" in plugins).toBe(false);
372
- await listener?.callback.call(client as never, "", "pos:one");
644
+ await listener?.callback.call(
645
+ client as never,
646
+ "",
647
+ "pos:one",
648
+ );
373
649
  await flushWrites();
374
650
  expect("pos:one" in plugins).toBe(false);
375
651
  });
376
652
 
377
653
  it("manager/teleport 跨分组迁移与同分组重排", async () => {
378
- const listener = app.console.listeners["manager/teleport"];
379
- const plugins = loader.config.plugins as Record<string, unknown>;
654
+ const listener =
655
+ app.console.listeners["manager/teleport"];
656
+ const plugins = loader.config.plugins as Record<
657
+ string,
658
+ unknown
659
+ >;
380
660
  // 跨分组:group:g1 → 根(index 0)
381
- await listener?.callback.call(client as never, "g1", "mov:me", "", 0);
661
+ await listener?.callback.call(
662
+ client as never,
663
+ "g1",
664
+ "mov:me",
665
+ "",
666
+ 0,
667
+ );
382
668
  await flushWrites();
383
669
  expect(plugins["mov:me"]).toEqual({ m: 1 });
384
- const group = plugins["group:g1"] as Record<string, unknown>;
670
+ const group = plugins["group:g1"] as Record<
671
+ string,
672
+ unknown
673
+ >;
385
674
  expect("mov:me" in group).toBe(false);
386
675
  // 运行时 fork 已改挂根作用域
387
676
  const dummy = loader.data["mov"] as Plugin;
388
677
  const fork = app.registry.get(dummy)?.children[0];
389
- expect(fork?.parent.scope.uid).toBe(loader.entry.scope.uid);
678
+ expect(fork?.parent.scope.uid).toBe(
679
+ loader.entry.scope.uid,
680
+ );
390
681
 
391
682
  // 同分组:仅重排键位置
392
- await listener?.callback.call(client as never, "", "mov:me", "", 0);
683
+ await listener?.callback.call(
684
+ client as never,
685
+ "",
686
+ "mov:me",
687
+ "",
688
+ 0,
689
+ );
393
690
  await flushWrites();
394
691
  expect(Object.keys(plugins)[0]).toBe("mov:me");
395
692
  });
396
693
 
397
- it("manager/* 失败统一回执 failed", async () => {
398
- const listener = app.console.listeners["manager/teleport"];
399
- await expect(
400
- listener?.callback.call(client as never, "no-such-group", "k", "", 0),
401
- ).rejects.toThrow("failed");
402
- });
694
+ itQuiet(
695
+ ["loader"],
696
+ "manager/* 失败统一回执 failed",
697
+ async () => {
698
+ const listener =
699
+ app.console.listeners["manager/teleport"];
700
+ await expect(
701
+ listener?.callback.call(
702
+ client as never,
703
+ "no-such-group",
704
+ "k",
705
+ "",
706
+ 0,
707
+ ),
708
+ ).rejects.toThrow("failed");
709
+ },
710
+ );
403
711
 
404
712
  it("manager/app-reload 替换全局配置并整进程重载", async () => {
405
- const listener = app.console.listeners["manager/app-reload"];
713
+ const listener =
714
+ app.console.listeners["manager/app-reload"];
406
715
  const writesBefore = loader.writes.length;
407
716
  await listener?.callback.call(client as never, {
408
717
  prefix: ["."],
@@ -411,11 +720,14 @@ describe("@koishi-ce/plugin-config", () => {
411
720
  expect(loader.config.prefix).toEqual(["."]);
412
721
  // plugins 部分被保留
413
722
  expect(loader.config.plugins).toBeTruthy();
414
- expect(loader.writes.length).toBeGreaterThan(writesBefore);
723
+ expect(loader.writes.length).toBeGreaterThan(
724
+ writesBefore,
725
+ );
415
726
  expect(loader.fullReloadCount).toBe(1);
416
727
  // 非静默写盘触发 config 事件 → config 服务刷新推送
417
728
  const messages = readSent().filter(
418
- (msg) => msg.type === "data" && msg.body.key === "config",
729
+ (msg) =>
730
+ msg.type === "data" && msg.body.key === "config",
419
731
  );
420
732
  expect(messages.length).toBeGreaterThan(0);
421
733
  });