@koishi-ce/plugin-config 1.1.4 → 1.1.6
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.
|
@@ -1,942 +0,0 @@
|
|
|
1
|
-
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
-
// Copyright (c) 2019-present Shigma and Koishijs contributors.
|
|
3
|
-
// Copyright (c) 2026-present Koishi-CE contributors.
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* @koishi-ce/plugin-config 的行为测试。
|
|
7
|
-
*
|
|
8
|
-
* 以 TestLoader(不落盘的 Loader 桩)装配真实应用:覆盖只读配置的跳过分支、
|
|
9
|
-
* 三个数据服务的注册与读取(packages / services / config)、ConfigWriter 的
|
|
10
|
-
* 全部 manager/* 事件(app-reload / reload / unload / remove / meta / teleport)
|
|
11
|
-
* 与配置树的增删改、PackageProvider 的运行时解析缓存与按需刷新链路。
|
|
12
|
-
*/
|
|
13
|
-
|
|
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";
|
|
27
|
-
import type { IncomingMessage } from "node:http";
|
|
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";
|
|
35
|
-
import {
|
|
36
|
-
App,
|
|
37
|
-
type Context,
|
|
38
|
-
type Dict,
|
|
39
|
-
Logger,
|
|
40
|
-
type Plugin,
|
|
41
|
-
type Universal,
|
|
42
|
-
} from "@koishi-ce/koishi";
|
|
43
|
-
import { Loader } from "@koishi-ce/loader";
|
|
44
|
-
import type { ConfigWriter } from "@koishi-ce/plugin-config";
|
|
45
|
-
import * as configPlugin from "@koishi-ce/plugin-config";
|
|
46
|
-
|
|
47
|
-
/** 出站消息形状 */
|
|
48
|
-
interface SentMessage {
|
|
49
|
-
type: string;
|
|
50
|
-
body: {
|
|
51
|
-
id?: number;
|
|
52
|
-
key?: string;
|
|
53
|
-
value?: unknown;
|
|
54
|
-
error?: string;
|
|
55
|
-
};
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/** 内存 WebSocket 桩 */
|
|
59
|
-
class FakeSocket {
|
|
60
|
-
sent: string[] = [];
|
|
61
|
-
// message 与 close 的监听器统一为同构签名(never 载荷),保证集合存取类型一致
|
|
62
|
-
private messageHandlers = new Set<
|
|
63
|
-
(event: never) => void
|
|
64
|
-
>();
|
|
65
|
-
private closeHandlers = new Set<(event: never) => void>();
|
|
66
|
-
|
|
67
|
-
send(data: string) {
|
|
68
|
-
this.sent.push(data);
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
addEventListener(
|
|
72
|
-
type: string,
|
|
73
|
-
listener: (event: never) => void,
|
|
74
|
-
) {
|
|
75
|
-
if (type === "message")
|
|
76
|
-
this.messageHandlers.add(listener);
|
|
77
|
-
if (type === "close") this.closeHandlers.add(listener);
|
|
78
|
-
}
|
|
79
|
-
|
|
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);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
get socket(): Universal.WebSocket {
|
|
91
|
-
return this as unknown as Universal.WebSocket;
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function fakeRequest() {
|
|
96
|
-
return {
|
|
97
|
-
headers: {},
|
|
98
|
-
socket: { remoteAddress: "127.0.0.1" },
|
|
99
|
-
} as unknown as IncomingMessage;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
function tick(ms = 20) {
|
|
103
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
104
|
-
}
|
|
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
|
-
|
|
128
|
-
/** Console 抽象基类的最小实现 */
|
|
129
|
-
class TestConsole extends Console {
|
|
130
|
-
resolveEntry(files: Entry.Files, key: string): string[] {
|
|
131
|
-
const list =
|
|
132
|
-
typeof files === "string" || Array.isArray(files)
|
|
133
|
-
? files
|
|
134
|
-
: files.prod;
|
|
135
|
-
return [String(list), key];
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
acceptClient(
|
|
139
|
-
socket: Universal.WebSocket,
|
|
140
|
-
request: IncomingMessage,
|
|
141
|
-
): Client {
|
|
142
|
-
let accepted: Client | undefined;
|
|
143
|
-
const dispose = this.ctx.on(
|
|
144
|
-
"console/connection",
|
|
145
|
-
(client) => {
|
|
146
|
-
accepted = client;
|
|
147
|
-
},
|
|
148
|
-
);
|
|
149
|
-
this.accept(socket, request);
|
|
150
|
-
dispose();
|
|
151
|
-
if (!accepted) throw new Error("client not accepted");
|
|
152
|
-
return accepted;
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/** loader 测试桩:import 走内存注册表,saveConfig 只记录,fullReload 只计数 */
|
|
157
|
-
class TestLoader extends Loader {
|
|
158
|
-
data: Dict<unknown> = Object.create(null);
|
|
159
|
-
writes: { filename: string; config: unknown }[] = [];
|
|
160
|
-
fullReloadCount = 0;
|
|
161
|
-
/** 每个名字被 import 的次数(断言失败缓存不再重复解析) */
|
|
162
|
-
importCounts: Dict<number> = Object.create(null);
|
|
163
|
-
|
|
164
|
-
constructor() {
|
|
165
|
-
super();
|
|
166
|
-
this.writable = true;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
override async import(name: string) {
|
|
170
|
-
this.importCounts[name] =
|
|
171
|
-
(this.importCounts[name] ?? 0) + 1;
|
|
172
|
-
if (name === "bad-plugin") {
|
|
173
|
-
throw new Error("cannot resolve bad-plugin");
|
|
174
|
-
}
|
|
175
|
-
return (this.data[name] ||= {
|
|
176
|
-
name,
|
|
177
|
-
apply: (ctx: Context) => {
|
|
178
|
-
ctx.accept();
|
|
179
|
-
},
|
|
180
|
-
});
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
override fullReload() {
|
|
184
|
-
this.fullReloadCount += 1;
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
protected override locateConfig(): Promise<never> {
|
|
188
|
-
throw new Error(
|
|
189
|
-
"test loader does not touch the file system",
|
|
190
|
-
);
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
protected override parseConfig(): Promise<never> {
|
|
194
|
-
throw new Error(
|
|
195
|
-
"test loader does not touch the file system",
|
|
196
|
-
);
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
protected override async saveConfig(
|
|
200
|
-
filename: string,
|
|
201
|
-
config: Context.Config,
|
|
202
|
-
): Promise<void> {
|
|
203
|
-
this.writes.push({ filename, config });
|
|
204
|
-
}
|
|
205
|
-
}
|
|
206
|
-
|
|
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
|
-
// external/ 约定目录下的未启用插件(含一个非插件包与一个无清单目录,
|
|
269
|
-
// 均不应进列表);有清单但包名不合插件命名的 lib-tool 也不收录
|
|
270
|
-
mkdirSync(join(fixtureRoot, "external/war-game"), {
|
|
271
|
-
recursive: true,
|
|
272
|
-
});
|
|
273
|
-
writeFileSync(
|
|
274
|
-
join(fixtureRoot, "external/war-game/package.json"),
|
|
275
|
-
JSON.stringify({
|
|
276
|
-
name: "koishi-plugin-war-game",
|
|
277
|
-
version: "0.1.0",
|
|
278
|
-
}),
|
|
279
|
-
);
|
|
280
|
-
mkdirSync(join(fixtureRoot, "external/plain-lib"), {
|
|
281
|
-
recursive: true,
|
|
282
|
-
});
|
|
283
|
-
writeFileSync(
|
|
284
|
-
join(fixtureRoot, "external/plain-lib/package.json"),
|
|
285
|
-
JSON.stringify({
|
|
286
|
-
name: "plain-lib",
|
|
287
|
-
version: "1.0.0",
|
|
288
|
-
}),
|
|
289
|
-
);
|
|
290
|
-
mkdirSync(join(fixtureRoot, "external/no-manifest"), {
|
|
291
|
-
recursive: true,
|
|
292
|
-
});
|
|
293
|
-
// 嵌套 monorepo(新模板 workspaces 声明形态):子包在二级深度可收录,
|
|
294
|
-
// monorepo 根(@scope/monorepo 命名)不进列表,node_modules 残留探针
|
|
295
|
-
// 被负向通配排除;plugins/ 一级的未启用包(盲区修复)同样可收录
|
|
296
|
-
writeFileSync(
|
|
297
|
-
join(fixtureRoot, "package.json"),
|
|
298
|
-
JSON.stringify({
|
|
299
|
-
name: "fixture-host",
|
|
300
|
-
private: true,
|
|
301
|
-
workspaces: [
|
|
302
|
-
"plugins/*",
|
|
303
|
-
"external/**",
|
|
304
|
-
"!external/**/node_modules/**",
|
|
305
|
-
],
|
|
306
|
-
}),
|
|
307
|
-
);
|
|
308
|
-
mkdirSync(
|
|
309
|
-
join(fixtureRoot, "external/infra/packages/war-lib"),
|
|
310
|
-
{ recursive: true },
|
|
311
|
-
);
|
|
312
|
-
writeFileSync(
|
|
313
|
-
join(
|
|
314
|
-
fixtureRoot,
|
|
315
|
-
"external/infra/packages/war-lib/package.json",
|
|
316
|
-
),
|
|
317
|
-
JSON.stringify({
|
|
318
|
-
name: "koishi-plugin-war-lib",
|
|
319
|
-
version: "0.2.0",
|
|
320
|
-
}),
|
|
321
|
-
);
|
|
322
|
-
writeFileSync(
|
|
323
|
-
join(fixtureRoot, "external/infra/package.json"),
|
|
324
|
-
JSON.stringify({
|
|
325
|
-
name: "@infra/monorepo",
|
|
326
|
-
version: "1.0.0",
|
|
327
|
-
private: true,
|
|
328
|
-
}),
|
|
329
|
-
);
|
|
330
|
-
mkdirSync(
|
|
331
|
-
join(fixtureRoot, "external/infra/node_modules/zzz"),
|
|
332
|
-
{ recursive: true },
|
|
333
|
-
);
|
|
334
|
-
writeFileSync(
|
|
335
|
-
join(
|
|
336
|
-
fixtureRoot,
|
|
337
|
-
"external/infra/node_modules/zzz/package.json",
|
|
338
|
-
),
|
|
339
|
-
JSON.stringify({
|
|
340
|
-
name: "koishi-plugin-zzz-pollution",
|
|
341
|
-
version: "1.0.0",
|
|
342
|
-
}),
|
|
343
|
-
);
|
|
344
|
-
mkdirSync(join(fixtureRoot, "plugins/local-tool"), {
|
|
345
|
-
recursive: true,
|
|
346
|
-
});
|
|
347
|
-
writeFileSync(
|
|
348
|
-
join(fixtureRoot, "plugins/local-tool/package.json"),
|
|
349
|
-
JSON.stringify({
|
|
350
|
-
name: "koishi-plugin-local-tool",
|
|
351
|
-
version: "0.3.0",
|
|
352
|
-
}),
|
|
353
|
-
);
|
|
354
|
-
loader.baseDir = fixtureRoot;
|
|
355
|
-
|
|
356
|
-
loader.config = {
|
|
357
|
-
plugins: {
|
|
358
|
-
$sfolded: true,
|
|
359
|
-
"gone:xyz": { $if: false },
|
|
360
|
-
"~disabled:q": { a: 1 },
|
|
361
|
-
"keep:abc": { v: 1 },
|
|
362
|
-
"pos:one": {},
|
|
363
|
-
"group:g1": {
|
|
364
|
-
"./plugins/webui/auth": {},
|
|
365
|
-
"./missing-pkg": {},
|
|
366
|
-
"inner:two": { t: 2 },
|
|
367
|
-
"mov:me": { m: 1 },
|
|
368
|
-
},
|
|
369
|
-
},
|
|
370
|
-
} as unknown as Context.Config;
|
|
371
|
-
|
|
372
|
-
const app = await loader.createApp();
|
|
373
|
-
// Console 基类的 static inject 是 cordis 3 旧形态,与 Plugin.Constructor 期待类型不兼容,仅做类型层转型
|
|
374
|
-
app.plugin(
|
|
375
|
-
TestConsole as unknown as Plugin.Constructor<App>,
|
|
376
|
-
);
|
|
377
|
-
app.plugin(configPlugin);
|
|
378
|
-
|
|
379
|
-
const service = () => app.console as TestConsole;
|
|
380
|
-
const writer = () =>
|
|
381
|
-
app.get("console.services.config") as ConfigWriter;
|
|
382
|
-
let socket: FakeSocket;
|
|
383
|
-
let client: Client;
|
|
384
|
-
|
|
385
|
-
/** 读取客户端已收到的全部消息 */
|
|
386
|
-
function readSent(): SentMessage[] {
|
|
387
|
-
return socket.sent.map(
|
|
388
|
-
(line) => JSON.parse(line) as SentMessage,
|
|
389
|
-
);
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
/** 等待写盘队列(合并窗口 setTimeout 0)落定 */
|
|
393
|
-
async function flushWrites() {
|
|
394
|
-
await tick(10);
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
beforeAll(async () => {
|
|
398
|
-
await app.start();
|
|
399
|
-
socket = new FakeSocket();
|
|
400
|
-
client = service().acceptClient(
|
|
401
|
-
socket.socket,
|
|
402
|
-
fakeRequest(),
|
|
403
|
-
);
|
|
404
|
-
await tick();
|
|
405
|
-
socket.sent.length = 0;
|
|
406
|
-
});
|
|
407
|
-
|
|
408
|
-
afterAll(async () => {
|
|
409
|
-
for (const entry of Object.values(service().entries)) {
|
|
410
|
-
entry.dispose();
|
|
411
|
-
}
|
|
412
|
-
await tick();
|
|
413
|
-
await app.stop();
|
|
414
|
-
rmSync(fixtureRoot, { recursive: true, force: true });
|
|
415
|
-
// 恢复域级阈值,避免同进程后续测试文件被连带静默
|
|
416
|
-
delete testLevels["loader"];
|
|
417
|
-
delete testLevels["app"];
|
|
418
|
-
});
|
|
419
|
-
|
|
420
|
-
describe("@koishi-ce/plugin-config", () => {
|
|
421
|
-
itQuiet(
|
|
422
|
-
["app"],
|
|
423
|
-
"无可写 loader 时仅告警并跳过装配",
|
|
424
|
-
async () => {
|
|
425
|
-
const bare = new App();
|
|
426
|
-
bare.plugin(
|
|
427
|
-
TestConsole as unknown as Plugin.Constructor<App>,
|
|
428
|
-
);
|
|
429
|
-
bare.plugin(configPlugin);
|
|
430
|
-
await bare.start();
|
|
431
|
-
expect(
|
|
432
|
-
bare.get("console.services.config"),
|
|
433
|
-
).toBeUndefined();
|
|
434
|
-
expect(
|
|
435
|
-
bare.get("console.services.packages"),
|
|
436
|
-
).toBeUndefined();
|
|
437
|
-
expect(
|
|
438
|
-
bare.get("console.services.services"),
|
|
439
|
-
).toBeUndefined();
|
|
440
|
-
await bare.stop();
|
|
441
|
-
},
|
|
442
|
-
);
|
|
443
|
-
|
|
444
|
-
it("挂载 packages / services / config 三个数据服务", () => {
|
|
445
|
-
expect(
|
|
446
|
-
app.get("console.services.packages"),
|
|
447
|
-
).toBeTruthy();
|
|
448
|
-
expect(
|
|
449
|
-
app.get("console.services.services"),
|
|
450
|
-
).toBeTruthy();
|
|
451
|
-
expect(app.get("console.services.config")).toBeTruthy();
|
|
452
|
-
});
|
|
453
|
-
|
|
454
|
-
describe("ConfigWriter.get()", () => {
|
|
455
|
-
it("过滤未加载项,分组递归展开,$ 与 ~ 键原样保留", async () => {
|
|
456
|
-
const result = await writer().get();
|
|
457
|
-
const plugins = result.plugins as Record<
|
|
458
|
-
string,
|
|
459
|
-
unknown
|
|
460
|
-
>;
|
|
461
|
-
// $if 为假的键不出现;$ 内部键与 ~ 停用键保留
|
|
462
|
-
expect("gone:xyz" in plugins).toBe(false);
|
|
463
|
-
expect(plugins["$sfolded"]).toBe(true);
|
|
464
|
-
expect(plugins["~disabled:q"]).toEqual({ a: 1 });
|
|
465
|
-
expect(plugins["keep:abc"]).toEqual({ v: 1 });
|
|
466
|
-
expect(plugins["group:g1"]).toEqual({
|
|
467
|
-
"./plugins/webui/auth": {},
|
|
468
|
-
"./missing-pkg": {},
|
|
469
|
-
"inner:two": { t: 2 },
|
|
470
|
-
"mov:me": { m: 1 },
|
|
471
|
-
});
|
|
472
|
-
});
|
|
473
|
-
});
|
|
474
|
-
|
|
475
|
-
describe("PackageProvider", () => {
|
|
476
|
-
it("收集本机包与 workspace 源码包并附全局设置条目", async () => {
|
|
477
|
-
const provider = app.get(
|
|
478
|
-
"console.services.packages",
|
|
479
|
-
) as unknown as {
|
|
480
|
-
get(): Promise<Dict<Record<string, unknown>>>;
|
|
481
|
-
};
|
|
482
|
-
const data = await provider.get();
|
|
483
|
-
// 全局设置条目(name 为空串)排在最前
|
|
484
|
-
expect(data[""]).toBeTruthy();
|
|
485
|
-
// workspace 路径键引用的源码包:带 paths 与运行时缓存
|
|
486
|
-
const auth = data["@koishi-ce/plugin-auth"];
|
|
487
|
-
expect(auth?.["paths"]).toEqual([
|
|
488
|
-
"./plugins/webui/auth",
|
|
489
|
-
]);
|
|
490
|
-
expect(auth?.["runtime"]).toBeTruthy();
|
|
491
|
-
// 本机 node_modules 扫描出的已装插件包(fixture 预置,见文件头说明)
|
|
492
|
-
expect(
|
|
493
|
-
data["@koishi-ce/plugin-fixture"],
|
|
494
|
-
).toBeTruthy();
|
|
495
|
-
});
|
|
496
|
-
|
|
497
|
-
it("扫描 external/ 约定目录收录未启用的 workspace 插件", async () => {
|
|
498
|
-
const provider = app.get(
|
|
499
|
-
"console.services.packages",
|
|
500
|
-
) as unknown as {
|
|
501
|
-
get(): Promise<Dict<Record<string, unknown>>>;
|
|
502
|
-
pathKeys: Dict<string>;
|
|
503
|
-
cache: Dict<Record<string, unknown>>;
|
|
504
|
-
};
|
|
505
|
-
const data = await provider.get();
|
|
506
|
-
// 命名合规的插件包:收录并标注相对路径键
|
|
507
|
-
const war = data["koishi-plugin-war-game"];
|
|
508
|
-
expect(war?.["workspace"]).toBe(true);
|
|
509
|
-
expect(war?.["paths"]).toEqual([
|
|
510
|
-
"./external/war-game",
|
|
511
|
-
]);
|
|
512
|
-
// 未启用包不预热运行时缓存(require 会提前求值模块)
|
|
513
|
-
expect(war?.["runtime"]).toBeUndefined();
|
|
514
|
-
// 「包名 → 配置键」反查已登记,request-runtime 按需解析可用
|
|
515
|
-
expect(
|
|
516
|
-
provider.pathKeys["koishi-plugin-war-game"],
|
|
517
|
-
).toBe("./external/war-game");
|
|
518
|
-
expect(
|
|
519
|
-
provider.cache["./external/war-game"],
|
|
520
|
-
).toBeUndefined();
|
|
521
|
-
// 非插件命名与无清单目录不进列表
|
|
522
|
-
expect(data["plain-lib"]).toBeUndefined();
|
|
523
|
-
expect(data["no-manifest"]).toBeUndefined();
|
|
524
|
-
});
|
|
525
|
-
|
|
526
|
-
it("workspaces 声明驱动收录嵌套 monorepo 子包与 plugins/ 未启用包", async () => {
|
|
527
|
-
const provider = app.get(
|
|
528
|
-
"console.services.packages",
|
|
529
|
-
) as unknown as {
|
|
530
|
-
get(): Promise<Dict<Record<string, unknown>>>;
|
|
531
|
-
};
|
|
532
|
-
const data = await provider.get();
|
|
533
|
-
// 嵌套子包(external/** 任意深度):收录并以深路径为键
|
|
534
|
-
const lib = data["koishi-plugin-war-lib"];
|
|
535
|
-
expect(lib?.["workspace"]).toBe(true);
|
|
536
|
-
expect(lib?.["paths"]).toEqual([
|
|
537
|
-
"./external/infra/packages/war-lib",
|
|
538
|
-
]);
|
|
539
|
-
expect(lib?.["runtime"]).toBeUndefined();
|
|
540
|
-
// plugins/ 一级的未启用包同样可收录(此前的可见性盲区)
|
|
541
|
-
expect(
|
|
542
|
-
data["koishi-plugin-local-tool"]?.["paths"],
|
|
543
|
-
).toEqual(["./plugins/local-tool"]);
|
|
544
|
-
// monorepo 根(@scope/monorepo 命名)不进列表
|
|
545
|
-
expect(data["@infra/monorepo"]).toBeUndefined();
|
|
546
|
-
// node_modules 残留探针被负向通配排除(即使命名合规)
|
|
547
|
-
expect(
|
|
548
|
-
data["koishi-plugin-zzz-pollution"],
|
|
549
|
-
).toBeUndefined();
|
|
550
|
-
});
|
|
551
|
-
|
|
552
|
-
it("readWorkspacePatterns:无清单 / 非数组 / 空数组时回退约定", async () => {
|
|
553
|
-
const { readWorkspacePatterns } = await import(
|
|
554
|
-
"../node/packages.ts"
|
|
555
|
-
);
|
|
556
|
-
const empty = mkdtempSync(
|
|
557
|
-
join(tmpdir(), "koishi-config-ws-"),
|
|
558
|
-
);
|
|
559
|
-
try {
|
|
560
|
-
// 无 package.json → 兜底约定
|
|
561
|
-
expect(readWorkspacePatterns(empty)).toEqual([
|
|
562
|
-
"plugins/*",
|
|
563
|
-
"external/*",
|
|
564
|
-
]);
|
|
565
|
-
// 非数组(yarn no-hoist 对象形态)与空数组同理
|
|
566
|
-
writeFileSync(
|
|
567
|
-
join(empty, "package.json"),
|
|
568
|
-
JSON.stringify({
|
|
569
|
-
workspaces: { packages: ["external/*"] },
|
|
570
|
-
}),
|
|
571
|
-
);
|
|
572
|
-
expect(readWorkspacePatterns(empty)).toEqual([
|
|
573
|
-
"plugins/*",
|
|
574
|
-
"external/*",
|
|
575
|
-
]);
|
|
576
|
-
writeFileSync(
|
|
577
|
-
join(empty, "package.json"),
|
|
578
|
-
JSON.stringify({ workspaces: [] }),
|
|
579
|
-
);
|
|
580
|
-
expect(readWorkspacePatterns(empty)).toEqual([
|
|
581
|
-
"plugins/*",
|
|
582
|
-
"external/*",
|
|
583
|
-
]);
|
|
584
|
-
// 合法声明原样透传(负向模式保留 ! 前缀)
|
|
585
|
-
writeFileSync(
|
|
586
|
-
join(empty, "package.json"),
|
|
587
|
-
JSON.stringify({
|
|
588
|
-
workspaces: [
|
|
589
|
-
"external/**",
|
|
590
|
-
"!external/**/node_modules/**",
|
|
591
|
-
],
|
|
592
|
-
}),
|
|
593
|
-
);
|
|
594
|
-
expect(readWorkspacePatterns(empty)).toEqual([
|
|
595
|
-
"external/**",
|
|
596
|
-
"!external/**/node_modules/**",
|
|
597
|
-
]);
|
|
598
|
-
} finally {
|
|
599
|
-
rmSync(empty, { recursive: true, force: true });
|
|
600
|
-
}
|
|
601
|
-
});
|
|
602
|
-
|
|
603
|
-
itQuiet(
|
|
604
|
-
["config"],
|
|
605
|
-
"request-runtime 按路径键 / 短名解析并刷新,失败结果同样缓存",
|
|
606
|
-
async () => {
|
|
607
|
-
const listener =
|
|
608
|
-
app.console.listeners["config/request-runtime"];
|
|
609
|
-
expect(listener).toBeTruthy();
|
|
610
|
-
const provider = app.get(
|
|
611
|
-
"console.services.packages",
|
|
612
|
-
) as unknown as {
|
|
613
|
-
cache: Dict<{ failed?: boolean }>;
|
|
614
|
-
pathKeys: Dict<string>;
|
|
615
|
-
};
|
|
616
|
-
// 失败路径:stub 的 bad-plugin 抛错,{ failed: true } 入缓存并
|
|
617
|
-
// 随数据下发——前端据以展示失败提示并停止重发请求
|
|
618
|
-
await listener?.callback.call(
|
|
619
|
-
client as never,
|
|
620
|
-
"bad-plugin",
|
|
621
|
-
);
|
|
622
|
-
await flushWrites();
|
|
623
|
-
expect(provider.cache["bad-plugin"]).toEqual({
|
|
624
|
-
failed: true,
|
|
625
|
-
});
|
|
626
|
-
// 重复请求命中失败缓存,不再触发 loader.import(防活锁刷屏)
|
|
627
|
-
const importsAfterFirst =
|
|
628
|
-
loader.importCounts["bad-plugin"] ?? 0;
|
|
629
|
-
expect(importsAfterFirst).toBeGreaterThan(0);
|
|
630
|
-
await listener?.callback.call(
|
|
631
|
-
client as never,
|
|
632
|
-
"bad-plugin",
|
|
633
|
-
);
|
|
634
|
-
await flushWrites();
|
|
635
|
-
expect(loader.importCounts["bad-plugin"] ?? 0).toBe(
|
|
636
|
-
importsAfterFirst,
|
|
637
|
-
);
|
|
638
|
-
// 成功路径:workspace 包名命中 pathKeys
|
|
639
|
-
await listener?.callback.call(
|
|
640
|
-
client as never,
|
|
641
|
-
"@koishi-ce/plugin-auth",
|
|
642
|
-
);
|
|
643
|
-
await flushWrites();
|
|
644
|
-
expect(
|
|
645
|
-
provider.pathKeys["@koishi-ce/plugin-auth"],
|
|
646
|
-
).toBe("./plugins/webui/auth");
|
|
647
|
-
expect(
|
|
648
|
-
provider.cache["./plugins/webui/auth"],
|
|
649
|
-
).toBeTruthy();
|
|
650
|
-
expect(
|
|
651
|
-
provider.cache["./plugins/webui/auth"]?.failed,
|
|
652
|
-
).toBeUndefined();
|
|
653
|
-
},
|
|
654
|
-
);
|
|
655
|
-
|
|
656
|
-
it("internal/runtime / fork / status 与 hmr/reload 触发运行时更新", async () => {
|
|
657
|
-
const dummy = loader.data["keep"] as Plugin;
|
|
658
|
-
const runtime = app.registry.get(dummy);
|
|
659
|
-
expect(runtime).toBeTruthy();
|
|
660
|
-
// keyFor 命中缓存:重新解析并去抖刷新
|
|
661
|
-
app.emit("internal/runtime", runtime as never);
|
|
662
|
-
// 未登记的插件:直接返回
|
|
663
|
-
app.emit("internal/runtime", {
|
|
664
|
-
runtime: { plugin: TestConsole },
|
|
665
|
-
} as never);
|
|
666
|
-
const fork = runtime?.children[0];
|
|
667
|
-
expect(fork).toBeTruthy();
|
|
668
|
-
app.emit("internal/fork", fork as never);
|
|
669
|
-
// internal/status 签名为 (scope, oldValue) 双参,仓库内监听者均不消费第二参,
|
|
670
|
-
// 补 undefined 占位满足调用形状且不改变运行时行为
|
|
671
|
-
app.emit(
|
|
672
|
-
"internal/status",
|
|
673
|
-
fork as never,
|
|
674
|
-
undefined as never,
|
|
675
|
-
);
|
|
676
|
-
app.emit("hmr/reload", [[dummy]] as never);
|
|
677
|
-
await tick(30);
|
|
678
|
-
expect(true).toBe(true);
|
|
679
|
-
});
|
|
680
|
-
});
|
|
681
|
-
|
|
682
|
-
describe("ServiceProvider", () => {
|
|
683
|
-
it("上报各服务的提供者上下文并在服务变动时刷新", async () => {
|
|
684
|
-
const provider = app.get(
|
|
685
|
-
"console.services.services",
|
|
686
|
-
) as unknown as {
|
|
687
|
-
get(): Promise<Dict<number>>;
|
|
688
|
-
};
|
|
689
|
-
const data = await provider.get();
|
|
690
|
-
expect(data["console"]).toBeGreaterThanOrEqual(0);
|
|
691
|
-
// 服务注册(set 会广播 internal/service)→ refresh → 客户端收到 services 数据
|
|
692
|
-
socket.sent.length = 0;
|
|
693
|
-
app.set(
|
|
694
|
-
"probe.svc" as never,
|
|
695
|
-
{ marker: true } as never,
|
|
696
|
-
);
|
|
697
|
-
await tick(30);
|
|
698
|
-
const messages = readSent().filter(
|
|
699
|
-
(msg) =>
|
|
700
|
-
msg.type === "data" &&
|
|
701
|
-
msg.body.key === "services",
|
|
702
|
-
);
|
|
703
|
-
expect(messages.length).toBeGreaterThan(0);
|
|
704
|
-
});
|
|
705
|
-
|
|
706
|
-
it("provide() 注册的服务同样上报(loader / watcher 形态)", async () => {
|
|
707
|
-
const provider = app.get(
|
|
708
|
-
"console.services.services",
|
|
709
|
-
) as unknown as {
|
|
710
|
-
get(): Promise<Dict<number>>;
|
|
711
|
-
};
|
|
712
|
-
// cordis 3.18 的 ctx.provide() 不给值定义自有 "ctx" 属性
|
|
713
|
-
// (只有 tracker 符号,经 traceable 代理的属性访问可达),
|
|
714
|
-
// descriptor 查询落空时须以属性访问兜底,否则配置页
|
|
715
|
-
// 恒显示「必需服务未加载」
|
|
716
|
-
app.provide("probe.plain", { marker: 1 });
|
|
717
|
-
const data = await provider.get();
|
|
718
|
-
expect(data["probe.plain"]).toBeGreaterThanOrEqual(0);
|
|
719
|
-
});
|
|
720
|
-
});
|
|
721
|
-
|
|
722
|
-
describe("ConfigWriter 事件", () => {
|
|
723
|
-
it("manager/meta 更新元数据键(含 null 删除)", async () => {
|
|
724
|
-
const listener =
|
|
725
|
-
app.console.listeners["manager/meta"];
|
|
726
|
-
await listener?.callback.call(
|
|
727
|
-
client as never,
|
|
728
|
-
"abc",
|
|
729
|
-
{
|
|
730
|
-
$label: "L",
|
|
731
|
-
$collapsed: null,
|
|
732
|
-
},
|
|
733
|
-
);
|
|
734
|
-
await flushWrites();
|
|
735
|
-
const plugins = loader.config.plugins as Record<
|
|
736
|
-
string,
|
|
737
|
-
Record<string, unknown>
|
|
738
|
-
>;
|
|
739
|
-
expect(plugins["keep:abc"]?.["$label"]).toBe("L");
|
|
740
|
-
expect(
|
|
741
|
-
"$collapsed" in (plugins["keep:abc"] ?? {}),
|
|
742
|
-
).toBe(false);
|
|
743
|
-
// 元数据键置于配置开头
|
|
744
|
-
expect(
|
|
745
|
-
Object.keys(plugins["keep:abc"] ?? {})[0],
|
|
746
|
-
).toBe("$label");
|
|
747
|
-
// 分组内插件的 meta(按 ident 递归定位)
|
|
748
|
-
await listener?.callback.call(
|
|
749
|
-
client as never,
|
|
750
|
-
"two",
|
|
751
|
-
{
|
|
752
|
-
$label: "G",
|
|
753
|
-
},
|
|
754
|
-
);
|
|
755
|
-
await flushWrites();
|
|
756
|
-
const group = plugins["group:g1"] as Record<
|
|
757
|
-
string,
|
|
758
|
-
Record<string, unknown>
|
|
759
|
-
>;
|
|
760
|
-
expect(group["inner:two"]?.["$label"]).toBe("G");
|
|
761
|
-
});
|
|
762
|
-
|
|
763
|
-
it("manager/reload 更新插件配置并保持键位置", async () => {
|
|
764
|
-
const listener =
|
|
765
|
-
app.console.listeners["manager/reload"];
|
|
766
|
-
await listener?.callback.call(
|
|
767
|
-
client as never,
|
|
768
|
-
"",
|
|
769
|
-
"keep:abc",
|
|
770
|
-
{
|
|
771
|
-
v: 2,
|
|
772
|
-
},
|
|
773
|
-
);
|
|
774
|
-
await flushWrites();
|
|
775
|
-
const plugins = loader.config.plugins as Record<
|
|
776
|
-
string,
|
|
777
|
-
unknown
|
|
778
|
-
>;
|
|
779
|
-
expect(plugins["keep:abc"]).toEqual({ v: 2 });
|
|
780
|
-
const dummy = loader.data["keep"] as Plugin;
|
|
781
|
-
expect(app.registry.get(dummy)?.config).toEqual({
|
|
782
|
-
v: 2,
|
|
783
|
-
});
|
|
784
|
-
// 运行期更新触发回写
|
|
785
|
-
expect(loader.writes.length).toBeGreaterThan(0);
|
|
786
|
-
});
|
|
787
|
-
|
|
788
|
-
it("manager/unload 停用插件(原位改 ~ 与按位置插入)", async () => {
|
|
789
|
-
const listener =
|
|
790
|
-
app.console.listeners["manager/unload"];
|
|
791
|
-
// 原位重命名
|
|
792
|
-
await listener?.callback.call(
|
|
793
|
-
client as never,
|
|
794
|
-
"",
|
|
795
|
-
"keep:abc",
|
|
796
|
-
{
|
|
797
|
-
v: 3,
|
|
798
|
-
},
|
|
799
|
-
);
|
|
800
|
-
await flushWrites();
|
|
801
|
-
const plugins = loader.config.plugins as Record<
|
|
802
|
-
string,
|
|
803
|
-
unknown
|
|
804
|
-
>;
|
|
805
|
-
expect("keep:abc" in plugins).toBe(false);
|
|
806
|
-
expect(plugins["~keep:abc"]).toEqual({ v: 3 });
|
|
807
|
-
const dummy = loader.data["keep"] as Plugin;
|
|
808
|
-
expect(app.registry.get(dummy)).toBeUndefined();
|
|
809
|
-
|
|
810
|
-
// 先补一个可停用键,再按 index 插入到指定位置
|
|
811
|
-
const reload =
|
|
812
|
-
app.console.listeners["manager/reload"];
|
|
813
|
-
await reload?.callback.call(
|
|
814
|
-
client as never,
|
|
815
|
-
"",
|
|
816
|
-
"back:two",
|
|
817
|
-
{
|
|
818
|
-
b: 1,
|
|
819
|
-
},
|
|
820
|
-
);
|
|
821
|
-
await flushWrites();
|
|
822
|
-
await listener?.callback.call(
|
|
823
|
-
client as never,
|
|
824
|
-
"",
|
|
825
|
-
"back:two",
|
|
826
|
-
{},
|
|
827
|
-
1,
|
|
828
|
-
);
|
|
829
|
-
await flushWrites();
|
|
830
|
-
const keys = Object.keys(
|
|
831
|
-
loader.config.plugins as object,
|
|
832
|
-
);
|
|
833
|
-
expect(keys.indexOf("~back:two")).toBe(1);
|
|
834
|
-
});
|
|
835
|
-
|
|
836
|
-
it("manager/remove 彻底移除插件(含 ~ 停用态键)", async () => {
|
|
837
|
-
const listener =
|
|
838
|
-
app.console.listeners["manager/remove"];
|
|
839
|
-
await listener?.callback.call(
|
|
840
|
-
client as never,
|
|
841
|
-
"",
|
|
842
|
-
"~keep:abc",
|
|
843
|
-
);
|
|
844
|
-
await flushWrites();
|
|
845
|
-
const plugins = loader.config.plugins as Record<
|
|
846
|
-
string,
|
|
847
|
-
unknown
|
|
848
|
-
>;
|
|
849
|
-
expect("keep:abc" in plugins).toBe(false);
|
|
850
|
-
expect("~keep:abc" in plugins).toBe(false);
|
|
851
|
-
await listener?.callback.call(
|
|
852
|
-
client as never,
|
|
853
|
-
"",
|
|
854
|
-
"pos:one",
|
|
855
|
-
);
|
|
856
|
-
await flushWrites();
|
|
857
|
-
expect("pos:one" in plugins).toBe(false);
|
|
858
|
-
});
|
|
859
|
-
|
|
860
|
-
it("manager/teleport 跨分组迁移与同分组重排", async () => {
|
|
861
|
-
const listener =
|
|
862
|
-
app.console.listeners["manager/teleport"];
|
|
863
|
-
const plugins = loader.config.plugins as Record<
|
|
864
|
-
string,
|
|
865
|
-
unknown
|
|
866
|
-
>;
|
|
867
|
-
// 跨分组:group:g1 → 根(index 0)
|
|
868
|
-
await listener?.callback.call(
|
|
869
|
-
client as never,
|
|
870
|
-
"g1",
|
|
871
|
-
"mov:me",
|
|
872
|
-
"",
|
|
873
|
-
0,
|
|
874
|
-
);
|
|
875
|
-
await flushWrites();
|
|
876
|
-
expect(plugins["mov:me"]).toEqual({ m: 1 });
|
|
877
|
-
const group = plugins["group:g1"] as Record<
|
|
878
|
-
string,
|
|
879
|
-
unknown
|
|
880
|
-
>;
|
|
881
|
-
expect("mov:me" in group).toBe(false);
|
|
882
|
-
// 运行时 fork 已改挂根作用域
|
|
883
|
-
const dummy = loader.data["mov"] as Plugin;
|
|
884
|
-
const fork = app.registry.get(dummy)?.children[0];
|
|
885
|
-
expect(fork?.parent.scope.uid).toBe(
|
|
886
|
-
loader.entry.scope.uid,
|
|
887
|
-
);
|
|
888
|
-
|
|
889
|
-
// 同分组:仅重排键位置
|
|
890
|
-
await listener?.callback.call(
|
|
891
|
-
client as never,
|
|
892
|
-
"",
|
|
893
|
-
"mov:me",
|
|
894
|
-
"",
|
|
895
|
-
0,
|
|
896
|
-
);
|
|
897
|
-
await flushWrites();
|
|
898
|
-
expect(Object.keys(plugins)[0]).toBe("mov:me");
|
|
899
|
-
});
|
|
900
|
-
|
|
901
|
-
itQuiet(
|
|
902
|
-
["loader"],
|
|
903
|
-
"manager/* 失败统一回执 failed",
|
|
904
|
-
async () => {
|
|
905
|
-
const listener =
|
|
906
|
-
app.console.listeners["manager/teleport"];
|
|
907
|
-
await expect(
|
|
908
|
-
listener?.callback.call(
|
|
909
|
-
client as never,
|
|
910
|
-
"no-such-group",
|
|
911
|
-
"k",
|
|
912
|
-
"",
|
|
913
|
-
0,
|
|
914
|
-
),
|
|
915
|
-
).rejects.toThrow("failed");
|
|
916
|
-
},
|
|
917
|
-
);
|
|
918
|
-
|
|
919
|
-
it("manager/app-reload 替换全局配置并整进程重载", async () => {
|
|
920
|
-
const listener =
|
|
921
|
-
app.console.listeners["manager/app-reload"];
|
|
922
|
-
const writesBefore = loader.writes.length;
|
|
923
|
-
await listener?.callback.call(client as never, {
|
|
924
|
-
prefix: ["."],
|
|
925
|
-
});
|
|
926
|
-
await flushWrites();
|
|
927
|
-
expect(loader.config.prefix).toEqual(["."]);
|
|
928
|
-
// plugins 部分被保留
|
|
929
|
-
expect(loader.config.plugins).toBeTruthy();
|
|
930
|
-
expect(loader.writes.length).toBeGreaterThan(
|
|
931
|
-
writesBefore,
|
|
932
|
-
);
|
|
933
|
-
expect(loader.fullReloadCount).toBe(1);
|
|
934
|
-
// 非静默写盘触发 config 事件 → config 服务刷新推送
|
|
935
|
-
const messages = readSent().filter(
|
|
936
|
-
(msg) =>
|
|
937
|
-
msg.type === "data" && msg.body.key === "config",
|
|
938
|
-
);
|
|
939
|
-
expect(messages.length).toBeGreaterThan(0);
|
|
940
|
-
});
|
|
941
|
-
});
|
|
942
|
-
});
|