@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.
- package/lib/index.d.ts +14 -2
- package/lib/index.mjs +18 -10
- package/package.json +8 -5
- package/src/__tests__/index.test.ts +417 -105
- package/src/browser/index.ts +10 -2
- package/src/node/index.ts +10 -2
- package/src/node/packages.ts +22 -7
- package/src/shared/packages.ts +74 -28
- package/src/shared/services.ts +17 -8
- package/src/shared/writer.ts +88 -27
|
@@ -11,13 +11,32 @@
|
|
|
11
11
|
* 与配置树的增删改、PackageProvider 的运行时解析缓存与按需刷新链路。
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import {
|
|
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 {
|
|
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: {
|
|
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<
|
|
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(
|
|
46
|
-
|
|
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(
|
|
51
|
-
|
|
52
|
-
|
|
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)
|
|
132
|
+
typeof files === "string" || Array.isArray(files)
|
|
133
|
+
? files
|
|
134
|
+
: files.prod;
|
|
76
135
|
return [String(list), key];
|
|
77
136
|
}
|
|
78
137
|
|
|
79
|
-
acceptClient(
|
|
138
|
+
acceptClient(
|
|
139
|
+
socket: Universal.WebSocket,
|
|
140
|
+
request: IncomingMessage,
|
|
141
|
+
): Client {
|
|
80
142
|
let accepted: Client | undefined;
|
|
81
|
-
const dispose = this.ctx.on(
|
|
82
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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 = () =>
|
|
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(
|
|
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(
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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(
|
|
201
|
-
|
|
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<
|
|
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(
|
|
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([
|
|
401
|
+
expect(auth?.["paths"]).toEqual([
|
|
402
|
+
"./plugins/webui/auth",
|
|
403
|
+
]);
|
|
234
404
|
expect(auth?.["runtime"]).toBeTruthy();
|
|
235
|
-
// 本机 node_modules
|
|
236
|
-
expect(
|
|
405
|
+
// 本机 node_modules 扫描出的已装插件包(fixture 预置,见文件头说明)
|
|
406
|
+
expect(
|
|
407
|
+
data["@koishi-ce/plugin-fixture"],
|
|
408
|
+
).toBeTruthy();
|
|
237
409
|
});
|
|
238
410
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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) =>
|
|
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 =
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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(
|
|
533
|
+
expect(
|
|
534
|
+
"$collapsed" in (plugins["keep:abc"] ?? {}),
|
|
535
|
+
).toBe(false);
|
|
312
536
|
// 元数据键置于配置开头
|
|
313
|
-
expect(
|
|
537
|
+
expect(
|
|
538
|
+
Object.keys(plugins["keep:abc"] ?? {})[0],
|
|
539
|
+
).toBe("$label");
|
|
314
540
|
// 分组内插件的 meta(按 ident 递归定位)
|
|
315
|
-
await listener?.callback.call(
|
|
316
|
-
|
|
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 =
|
|
328
|
-
|
|
329
|
-
|
|
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<
|
|
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({
|
|
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 =
|
|
582
|
+
const listener =
|
|
583
|
+
app.console.listeners["manager/unload"];
|
|
342
584
|
// 原位重命名
|
|
343
|
-
await listener?.callback.call(
|
|
344
|
-
|
|
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<
|
|
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 =
|
|
355
|
-
|
|
356
|
-
|
|
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(
|
|
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(
|
|
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 =
|
|
367
|
-
|
|
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<
|
|
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(
|
|
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 =
|
|
379
|
-
|
|
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(
|
|
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<
|
|
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(
|
|
678
|
+
expect(fork?.parent.scope.uid).toBe(
|
|
679
|
+
loader.entry.scope.uid,
|
|
680
|
+
);
|
|
390
681
|
|
|
391
682
|
// 同分组:仅重排键位置
|
|
392
|
-
await listener?.callback.call(
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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 =
|
|
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(
|
|
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) =>
|
|
729
|
+
(msg) =>
|
|
730
|
+
msg.type === "data" && msg.body.key === "config",
|
|
419
731
|
);
|
|
420
732
|
expect(messages.length).toBeGreaterThan(0);
|
|
421
733
|
});
|