@koishi-ce/plugin-config 1.0.1 → 1.0.3
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 +1 -1
- package/lib/index.mjs +1 -1
- package/package.json +2 -2
- package/src/__tests__/index.test.ts +419 -0
- package/src/node/packages.ts +1 -1
package/lib/index.d.ts
CHANGED
|
@@ -233,6 +233,6 @@ declare const name = "config";
|
|
|
233
233
|
declare const inject: string[];
|
|
234
234
|
type Config = Record<never, never>;
|
|
235
235
|
declare const Config: Schema<Config>;
|
|
236
|
-
declare function apply(ctx: Context):
|
|
236
|
+
declare function apply(ctx: Context): void;
|
|
237
237
|
//#endregion
|
|
238
238
|
export { Config, ConfigWriter, PackageProvider, ServiceProvider, apply, inject, name };
|
package/lib/index.mjs
CHANGED
|
@@ -524,7 +524,7 @@ var PackageProvider$1 = class extends PackageProvider {
|
|
|
524
524
|
try {
|
|
525
525
|
await walkPlugins(this.ctx.loader?.config?.plugins, async (key) => {
|
|
526
526
|
const path = key.split(":", 1)[0];
|
|
527
|
-
if (!path
|
|
527
|
+
if (!path?.startsWith("./")) return;
|
|
528
528
|
const object = await this.scanner.loadPath(resolve(this.scanner.baseDir, path));
|
|
529
529
|
if (!object) return;
|
|
530
530
|
const name = object.package.name;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@koishi-ce/plugin-config",
|
|
3
3
|
"description": "Manage your bots and plugins with console",
|
|
4
|
-
"version": "1.0.
|
|
4
|
+
"version": "1.0.3",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.mjs",
|
|
7
7
|
"types": "lib/index.d.ts",
|
|
@@ -73,6 +73,6 @@
|
|
|
73
73
|
"dependencies": {
|
|
74
74
|
"@koishi-ce/console": "^1.0.0",
|
|
75
75
|
"@koishi-ce/loader": "^1.0.1",
|
|
76
|
-
"@koishi-ce/registry": "^1.0.
|
|
76
|
+
"@koishi-ce/registry": "^1.0.3"
|
|
77
77
|
}
|
|
78
78
|
}
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @koishi-ce/plugin-config 的行为测试。
|
|
3
|
+
*
|
|
4
|
+
* 以 TestLoader(不落盘的 Loader 桩)装配真实应用:覆盖只读配置的跳过分支、
|
|
5
|
+
* 三个数据服务的注册与读取(packages / services / config)、ConfigWriter 的
|
|
6
|
+
* 全部 manager/* 事件(app-reload / reload / unload / remove / meta / teleport)
|
|
7
|
+
* 与配置树的增删改、PackageProvider 的运行时解析缓存与按需刷新链路。
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { afterAll, beforeAll, describe, expect, it } from "bun:test";
|
|
11
|
+
import type { IncomingMessage } from "node:http";
|
|
12
|
+
import { type Client, Console, type Entry } from "@koishi-ce/console";
|
|
13
|
+
import {
|
|
14
|
+
App,
|
|
15
|
+
type Context,
|
|
16
|
+
type Dict,
|
|
17
|
+
type Plugin,
|
|
18
|
+
type Universal,
|
|
19
|
+
} from "@koishi-ce/koishi";
|
|
20
|
+
import { Loader } from "@koishi-ce/loader";
|
|
21
|
+
import type { ConfigWriter } from "@koishi-ce/plugin-config";
|
|
22
|
+
import * as configPlugin from "@koishi-ce/plugin-config";
|
|
23
|
+
|
|
24
|
+
/** 出站消息形状 */
|
|
25
|
+
interface SentMessage {
|
|
26
|
+
type: string;
|
|
27
|
+
body: { id?: number; key?: string; value?: unknown; error?: string };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 内存 WebSocket 桩 */
|
|
31
|
+
class FakeSocket {
|
|
32
|
+
sent: string[] = [];
|
|
33
|
+
// message 与 close 的监听器统一为同构签名(never 载荷),保证集合存取类型一致
|
|
34
|
+
private messageHandlers = new Set<(event: never) => void>();
|
|
35
|
+
private closeHandlers = new Set<(event: never) => void>();
|
|
36
|
+
|
|
37
|
+
send(data: string) {
|
|
38
|
+
this.sent.push(data);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
addEventListener(type: string, listener: (event: never) => void) {
|
|
42
|
+
if (type === "message") this.messageHandlers.add(listener);
|
|
43
|
+
if (type === "close") this.closeHandlers.add(listener);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
removeEventListener(type: string, listener: (event: never) => void) {
|
|
47
|
+
if (type === "message") this.messageHandlers.delete(listener);
|
|
48
|
+
if (type === "close") this.closeHandlers.delete(listener);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
get socket(): Universal.WebSocket {
|
|
52
|
+
return this as unknown as Universal.WebSocket;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function fakeRequest() {
|
|
57
|
+
return {
|
|
58
|
+
headers: {},
|
|
59
|
+
socket: { remoteAddress: "127.0.0.1" },
|
|
60
|
+
} as unknown as IncomingMessage;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function tick(ms = 20) {
|
|
64
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Console 抽象基类的最小实现 */
|
|
68
|
+
class TestConsole extends Console {
|
|
69
|
+
resolveEntry(files: Entry.Files, key: string): string[] {
|
|
70
|
+
const list =
|
|
71
|
+
typeof files === "string" || Array.isArray(files) ? files : files.prod;
|
|
72
|
+
return [String(list), key];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
acceptClient(socket: Universal.WebSocket, request: IncomingMessage): Client {
|
|
76
|
+
let accepted: Client | undefined;
|
|
77
|
+
const dispose = this.ctx.on("console/connection", (client) => {
|
|
78
|
+
accepted = client;
|
|
79
|
+
});
|
|
80
|
+
this.accept(socket, request);
|
|
81
|
+
dispose();
|
|
82
|
+
if (!accepted) throw new Error("client not accepted");
|
|
83
|
+
return accepted;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** loader 测试桩:import 走内存注册表,saveConfig 只记录,fullReload 只计数 */
|
|
88
|
+
class TestLoader extends Loader {
|
|
89
|
+
data: Dict<unknown> = Object.create(null);
|
|
90
|
+
writes: { filename: string; config: unknown }[] = [];
|
|
91
|
+
fullReloadCount = 0;
|
|
92
|
+
|
|
93
|
+
constructor() {
|
|
94
|
+
super();
|
|
95
|
+
this.writable = true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
override async import(name: string) {
|
|
99
|
+
if (name === "bad-plugin") {
|
|
100
|
+
throw new Error("cannot resolve bad-plugin");
|
|
101
|
+
}
|
|
102
|
+
return (this.data[name] ||= {
|
|
103
|
+
name,
|
|
104
|
+
apply: (ctx: Context) => {
|
|
105
|
+
ctx.accept();
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
override fullReload() {
|
|
111
|
+
this.fullReloadCount += 1;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
protected override locateConfig(): Promise<never> {
|
|
115
|
+
throw new Error("test loader does not touch the file system");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
protected override parseConfig(): Promise<never> {
|
|
119
|
+
throw new Error("test loader does not touch the file system");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
protected override async saveConfig(
|
|
123
|
+
filename: string,
|
|
124
|
+
config: Context.Config,
|
|
125
|
+
): Promise<void> {
|
|
126
|
+
this.writes.push({ filename, config });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const loader = new TestLoader();
|
|
131
|
+
loader.config = {
|
|
132
|
+
plugins: {
|
|
133
|
+
$sfolded: true,
|
|
134
|
+
"gone:xyz": { $if: false },
|
|
135
|
+
"~disabled:q": { a: 1 },
|
|
136
|
+
"keep:abc": { v: 1 },
|
|
137
|
+
"pos:one": {},
|
|
138
|
+
"group:g1": {
|
|
139
|
+
"./plugins/webui/auth": {},
|
|
140
|
+
"./missing-pkg": {},
|
|
141
|
+
"inner:two": { t: 2 },
|
|
142
|
+
"mov:me": { m: 1 },
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
} as unknown as Context.Config;
|
|
146
|
+
|
|
147
|
+
const app = await loader.createApp();
|
|
148
|
+
// Console 基类的 static inject 是 cordis 3 旧形态,与 Plugin.Constructor 期待类型不兼容,仅做类型层转型
|
|
149
|
+
app.plugin(TestConsole as unknown as Plugin.Constructor<App>);
|
|
150
|
+
app.plugin(configPlugin);
|
|
151
|
+
|
|
152
|
+
const service = () => app.console as TestConsole;
|
|
153
|
+
const writer = () => app.get("console.services.config") as ConfigWriter;
|
|
154
|
+
let socket: FakeSocket;
|
|
155
|
+
let client: Client;
|
|
156
|
+
|
|
157
|
+
/** 读取客户端已收到的全部消息 */
|
|
158
|
+
function readSent(): SentMessage[] {
|
|
159
|
+
return socket.sent.map((line) => JSON.parse(line) as SentMessage);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** 等待写盘队列(合并窗口 setTimeout 0)落定 */
|
|
163
|
+
async function flushWrites() {
|
|
164
|
+
await tick(10);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
beforeAll(async () => {
|
|
168
|
+
await app.start();
|
|
169
|
+
socket = new FakeSocket();
|
|
170
|
+
client = service().acceptClient(socket.socket, fakeRequest());
|
|
171
|
+
await tick();
|
|
172
|
+
socket.sent.length = 0;
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
afterAll(async () => {
|
|
176
|
+
for (const entry of Object.values(service().entries)) {
|
|
177
|
+
entry.dispose();
|
|
178
|
+
}
|
|
179
|
+
await tick();
|
|
180
|
+
await app.stop();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("@koishi-ce/plugin-config", () => {
|
|
184
|
+
it("无可写 loader 时仅告警并跳过装配", async () => {
|
|
185
|
+
const bare = new App();
|
|
186
|
+
bare.plugin(TestConsole as unknown as Plugin.Constructor<App>);
|
|
187
|
+
bare.plugin(configPlugin);
|
|
188
|
+
await bare.start();
|
|
189
|
+
expect(bare.get("console.services.config")).toBeUndefined();
|
|
190
|
+
expect(bare.get("console.services.packages")).toBeUndefined();
|
|
191
|
+
expect(bare.get("console.services.services")).toBeUndefined();
|
|
192
|
+
await bare.stop();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("挂载 packages / services / config 三个数据服务", () => {
|
|
196
|
+
expect(app.get("console.services.packages")).toBeTruthy();
|
|
197
|
+
expect(app.get("console.services.services")).toBeTruthy();
|
|
198
|
+
expect(app.get("console.services.config")).toBeTruthy();
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe("ConfigWriter.get()", () => {
|
|
202
|
+
it("过滤未加载项,分组递归展开,$ 与 ~ 键原样保留", async () => {
|
|
203
|
+
const result = await writer().get();
|
|
204
|
+
const plugins = result.plugins as Record<string, unknown>;
|
|
205
|
+
// $if 为假的键不出现;$ 内部键与 ~ 停用键保留
|
|
206
|
+
expect("gone:xyz" in plugins).toBe(false);
|
|
207
|
+
expect(plugins["$sfolded"]).toBe(true);
|
|
208
|
+
expect(plugins["~disabled:q"]).toEqual({ a: 1 });
|
|
209
|
+
expect(plugins["keep:abc"]).toEqual({ v: 1 });
|
|
210
|
+
expect(plugins["group:g1"]).toEqual({
|
|
211
|
+
"./plugins/webui/auth": {},
|
|
212
|
+
"./missing-pkg": {},
|
|
213
|
+
"inner:two": { t: 2 },
|
|
214
|
+
"mov:me": { m: 1 },
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe("PackageProvider", () => {
|
|
220
|
+
it("收集本机包与 workspace 源码包并附全局设置条目", async () => {
|
|
221
|
+
const provider = app.get("console.services.packages") as unknown as {
|
|
222
|
+
get(): Promise<Dict<Record<string, unknown>>>;
|
|
223
|
+
};
|
|
224
|
+
const data = await provider.get();
|
|
225
|
+
// 全局设置条目(name 为空串)排在最前
|
|
226
|
+
expect(data[""]).toBeTruthy();
|
|
227
|
+
// workspace 路径键引用的源码包:带 paths 与运行时缓存
|
|
228
|
+
const auth = data["@koishi-ce/plugin-auth"];
|
|
229
|
+
expect(auth?.["paths"]).toEqual(["./plugins/webui/auth"]);
|
|
230
|
+
expect(auth?.["runtime"]).toBeTruthy();
|
|
231
|
+
// 本机 node_modules 扫描出的已装插件包
|
|
232
|
+
expect(data["@koishi-ce/plugin-server"]).toBeTruthy();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("request-runtime 按路径键 / 短名解析并刷新,解析失败不缓存", async () => {
|
|
236
|
+
const listener = app.console.listeners["config/request-runtime"];
|
|
237
|
+
expect(listener).toBeTruthy();
|
|
238
|
+
// 失败路径:stub 的 bad-plugin 抛错,返回 { failed: true } 不入缓存
|
|
239
|
+
await listener?.callback.call(client as never, "bad-plugin");
|
|
240
|
+
await flushWrites();
|
|
241
|
+
// 成功路径:workspace 包名命中 pathKeys
|
|
242
|
+
await listener?.callback.call(client as never, "@koishi-ce/plugin-auth");
|
|
243
|
+
await flushWrites();
|
|
244
|
+
const provider = app.get("console.services.packages") as unknown as {
|
|
245
|
+
cache: Dict<unknown>;
|
|
246
|
+
pathKeys: Dict<string>;
|
|
247
|
+
};
|
|
248
|
+
expect(provider.pathKeys["@koishi-ce/plugin-auth"]).toBe(
|
|
249
|
+
"./plugins/webui/auth",
|
|
250
|
+
);
|
|
251
|
+
expect(provider.cache["./plugins/webui/auth"]).toBeTruthy();
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("internal/runtime / fork / status 与 hmr/reload 触发运行时更新", async () => {
|
|
255
|
+
const dummy = loader.data["keep"] as Plugin;
|
|
256
|
+
const runtime = app.registry.get(dummy);
|
|
257
|
+
expect(runtime).toBeTruthy();
|
|
258
|
+
// keyFor 命中缓存:重新解析并去抖刷新
|
|
259
|
+
app.emit("internal/runtime", runtime as never);
|
|
260
|
+
// 未登记的插件:直接返回
|
|
261
|
+
app.emit("internal/runtime", {
|
|
262
|
+
runtime: { plugin: TestConsole },
|
|
263
|
+
} as never);
|
|
264
|
+
const fork = runtime?.children[0];
|
|
265
|
+
expect(fork).toBeTruthy();
|
|
266
|
+
app.emit("internal/fork", fork as never);
|
|
267
|
+
// internal/status 签名为 (scope, oldValue) 双参,仓库内监听者均不消费第二参,
|
|
268
|
+
// 补 undefined 占位满足调用形状且不改变运行时行为
|
|
269
|
+
app.emit("internal/status", fork as never, undefined as never);
|
|
270
|
+
app.emit("hmr/reload", [[dummy]] as never);
|
|
271
|
+
await tick(30);
|
|
272
|
+
expect(true).toBe(true);
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
describe("ServiceProvider", () => {
|
|
277
|
+
it("上报各服务的提供者上下文并在服务变动时刷新", async () => {
|
|
278
|
+
const provider = app.get("console.services.services") as unknown as {
|
|
279
|
+
get(): Promise<Dict<number>>;
|
|
280
|
+
};
|
|
281
|
+
const data = await provider.get();
|
|
282
|
+
expect(data["console"]).toBeGreaterThanOrEqual(0);
|
|
283
|
+
// 服务注册(set 会广播 internal/service)→ refresh → 客户端收到 services 数据
|
|
284
|
+
socket.sent.length = 0;
|
|
285
|
+
app.set("probe.svc" as never, { marker: true } as never);
|
|
286
|
+
await tick(30);
|
|
287
|
+
const messages = readSent().filter(
|
|
288
|
+
(msg) => msg.type === "data" && msg.body.key === "services",
|
|
289
|
+
);
|
|
290
|
+
expect(messages.length).toBeGreaterThan(0);
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
describe("ConfigWriter 事件", () => {
|
|
295
|
+
it("manager/meta 更新元数据键(含 null 删除)", async () => {
|
|
296
|
+
const listener = app.console.listeners["manager/meta"];
|
|
297
|
+
await listener?.callback.call(client as never, "abc", {
|
|
298
|
+
$label: "L",
|
|
299
|
+
$collapsed: null,
|
|
300
|
+
});
|
|
301
|
+
await flushWrites();
|
|
302
|
+
const plugins = loader.config.plugins as Record<
|
|
303
|
+
string,
|
|
304
|
+
Record<string, unknown>
|
|
305
|
+
>;
|
|
306
|
+
expect(plugins["keep:abc"]?.["$label"]).toBe("L");
|
|
307
|
+
expect("$collapsed" in (plugins["keep:abc"] ?? {})).toBe(false);
|
|
308
|
+
// 元数据键置于配置开头
|
|
309
|
+
expect(Object.keys(plugins["keep:abc"] ?? {})[0]).toBe("$label");
|
|
310
|
+
// 分组内插件的 meta(按 ident 递归定位)
|
|
311
|
+
await listener?.callback.call(client as never, "two", {
|
|
312
|
+
$label: "G",
|
|
313
|
+
});
|
|
314
|
+
await flushWrites();
|
|
315
|
+
const group = plugins["group:g1"] as Record<
|
|
316
|
+
string,
|
|
317
|
+
Record<string, unknown>
|
|
318
|
+
>;
|
|
319
|
+
expect(group["inner:two"]?.["$label"]).toBe("G");
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it("manager/reload 更新插件配置并保持键位置", async () => {
|
|
323
|
+
const listener = app.console.listeners["manager/reload"];
|
|
324
|
+
await listener?.callback.call(client as never, "", "keep:abc", {
|
|
325
|
+
v: 2,
|
|
326
|
+
});
|
|
327
|
+
await flushWrites();
|
|
328
|
+
const plugins = loader.config.plugins as Record<string, unknown>;
|
|
329
|
+
expect(plugins["keep:abc"]).toEqual({ v: 2 });
|
|
330
|
+
const dummy = loader.data["keep"] as Plugin;
|
|
331
|
+
expect(app.registry.get(dummy)?.config).toEqual({ v: 2 });
|
|
332
|
+
// 运行期更新触发回写
|
|
333
|
+
expect(loader.writes.length).toBeGreaterThan(0);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it("manager/unload 停用插件(原位改 ~ 与按位置插入)", async () => {
|
|
337
|
+
const listener = app.console.listeners["manager/unload"];
|
|
338
|
+
// 原位重命名
|
|
339
|
+
await listener?.callback.call(client as never, "", "keep:abc", {
|
|
340
|
+
v: 3,
|
|
341
|
+
});
|
|
342
|
+
await flushWrites();
|
|
343
|
+
const plugins = loader.config.plugins as Record<string, unknown>;
|
|
344
|
+
expect("keep:abc" in plugins).toBe(false);
|
|
345
|
+
expect(plugins["~keep:abc"]).toEqual({ v: 3 });
|
|
346
|
+
const dummy = loader.data["keep"] as Plugin;
|
|
347
|
+
expect(app.registry.get(dummy)).toBeUndefined();
|
|
348
|
+
|
|
349
|
+
// 先补一个可停用键,再按 index 插入到指定位置
|
|
350
|
+
const reload = app.console.listeners["manager/reload"];
|
|
351
|
+
await reload?.callback.call(client as never, "", "back:two", {
|
|
352
|
+
b: 1,
|
|
353
|
+
});
|
|
354
|
+
await flushWrites();
|
|
355
|
+
await listener?.callback.call(client as never, "", "back:two", {}, 1);
|
|
356
|
+
await flushWrites();
|
|
357
|
+
const keys = Object.keys(loader.config.plugins as object);
|
|
358
|
+
expect(keys.indexOf("~back:two")).toBe(1);
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("manager/remove 彻底移除插件(含 ~ 停用态键)", async () => {
|
|
362
|
+
const listener = app.console.listeners["manager/remove"];
|
|
363
|
+
await listener?.callback.call(client as never, "", "~keep:abc");
|
|
364
|
+
await flushWrites();
|
|
365
|
+
const plugins = loader.config.plugins as Record<string, unknown>;
|
|
366
|
+
expect("keep:abc" in plugins).toBe(false);
|
|
367
|
+
expect("~keep:abc" in plugins).toBe(false);
|
|
368
|
+
await listener?.callback.call(client as never, "", "pos:one");
|
|
369
|
+
await flushWrites();
|
|
370
|
+
expect("pos:one" in plugins).toBe(false);
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it("manager/teleport 跨分组迁移与同分组重排", async () => {
|
|
374
|
+
const listener = app.console.listeners["manager/teleport"];
|
|
375
|
+
const plugins = loader.config.plugins as Record<string, unknown>;
|
|
376
|
+
// 跨分组:group:g1 → 根(index 0)
|
|
377
|
+
await listener?.callback.call(client as never, "g1", "mov:me", "", 0);
|
|
378
|
+
await flushWrites();
|
|
379
|
+
expect(plugins["mov:me"]).toEqual({ m: 1 });
|
|
380
|
+
const group = plugins["group:g1"] as Record<string, unknown>;
|
|
381
|
+
expect("mov:me" in group).toBe(false);
|
|
382
|
+
// 运行时 fork 已改挂根作用域
|
|
383
|
+
const dummy = loader.data["mov"] as Plugin;
|
|
384
|
+
const fork = app.registry.get(dummy)?.children[0];
|
|
385
|
+
expect(fork?.parent.scope.uid).toBe(loader.entry.scope.uid);
|
|
386
|
+
|
|
387
|
+
// 同分组:仅重排键位置
|
|
388
|
+
await listener?.callback.call(client as never, "", "mov:me", "", 0);
|
|
389
|
+
await flushWrites();
|
|
390
|
+
expect(Object.keys(plugins)[0]).toBe("mov:me");
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
it("manager/* 失败统一回执 failed", async () => {
|
|
394
|
+
const listener = app.console.listeners["manager/teleport"];
|
|
395
|
+
await expect(
|
|
396
|
+
listener?.callback.call(client as never, "no-such-group", "k", "", 0),
|
|
397
|
+
).rejects.toThrow("failed");
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it("manager/app-reload 替换全局配置并整进程重载", async () => {
|
|
401
|
+
const listener = app.console.listeners["manager/app-reload"];
|
|
402
|
+
const writesBefore = loader.writes.length;
|
|
403
|
+
await listener?.callback.call(client as never, {
|
|
404
|
+
prefix: ["."],
|
|
405
|
+
});
|
|
406
|
+
await flushWrites();
|
|
407
|
+
expect(loader.config.prefix).toEqual(["."]);
|
|
408
|
+
// plugins 部分被保留
|
|
409
|
+
expect(loader.config.plugins).toBeTruthy();
|
|
410
|
+
expect(loader.writes.length).toBeGreaterThan(writesBefore);
|
|
411
|
+
expect(loader.fullReloadCount).toBe(1);
|
|
412
|
+
// 非静默写盘触发 config 事件 → config 服务刷新推送
|
|
413
|
+
const messages = readSent().filter(
|
|
414
|
+
(msg) => msg.type === "data" && msg.body.key === "config",
|
|
415
|
+
);
|
|
416
|
+
expect(messages.length).toBeGreaterThan(0);
|
|
417
|
+
});
|
|
418
|
+
});
|
|
419
|
+
});
|
package/src/node/packages.ts
CHANGED
|
@@ -89,7 +89,7 @@ export class PackageProvider extends shared.PackageProvider {
|
|
|
89
89
|
async (key) => {
|
|
90
90
|
// 配置键形如 ./plugins/webui/config:uid,取 : 前的路径部分
|
|
91
91
|
const path = key.split(":", 1)[0];
|
|
92
|
-
if (!path
|
|
92
|
+
if (!path?.startsWith("./")) return;
|
|
93
93
|
const object = await this.scanner.loadPath(
|
|
94
94
|
resolve(this.scanner.baseDir, path),
|
|
95
95
|
);
|