@koishi-ce/plugin-market 1.1.3 → 1.2.0

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.
@@ -0,0 +1,380 @@
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
+ import { expect, it, mock } from "bun:test";
6
+ import {
7
+ mkdtempSync,
8
+ rmSync,
9
+ writeFileSync,
10
+ } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import memory from "@koishi-ce/plugin-database-memory";
14
+
15
+ /**
16
+ * market 插件测试:
17
+ * - 子进程(bun 安装)经 mock.module 拦截 installer/proc.ts(spawnBun 封装),不落盘不联网安装;
18
+ * - registry 网络请求由进程内 Bun.serve 提供(registry 协议的最小 JSON);
19
+ * - 宿主环境(loader / cwd)使用 FakeLoader 与临时目录 + chdir,
20
+ * Installer 的 override 写盘只会作用于临时 package.json。
21
+ */
22
+
23
+ /** 已发生的子进程调用(参数列表)。 */
24
+ export const spawnCalls: string[][] = [];
25
+ /** 控制下一次子进程的退出码与触发事件。 */
26
+ let nextExitCode = 0;
27
+ let nextSpawnError = false;
28
+
29
+ /** let 绑定无法跨模块直接赋值,用例经以下 setter 控制子进程桩。 */
30
+ export function setNextExitCode(code: number) {
31
+ nextExitCode = code;
32
+ }
33
+
34
+ export function setNextSpawnError(flag: boolean) {
35
+ nextSpawnError = flag;
36
+ }
37
+
38
+ const spawnBunMock = (_args: string[], _cwd: string) => {
39
+ spawnCalls.push(_args);
40
+ return {
41
+ on(event: string, cb: (code?: number) => void) {
42
+ if (nextSpawnError) {
43
+ if (event === "error") setImmediate(() => cb());
44
+ } else if (event === "exit") {
45
+ setImmediate(() => cb(nextExitCode));
46
+ }
47
+ return this;
48
+ },
49
+ stderr: {
50
+ on(event: string, cb: (data: Buffer) => void) {
51
+ // 同步派发:异步派发会晚于用例的日志静默窗口,导致转发告警漏出刷屏
52
+ if (event === "data")
53
+ cb(Buffer.from("stderr line\n"));
54
+ return this;
55
+ },
56
+ },
57
+ stdout: {
58
+ on(event: string, cb: (data: Buffer) => void) {
59
+ if (event === "data")
60
+ cb(Buffer.from("stdout line\n"));
61
+ return this;
62
+ },
63
+ },
64
+ };
65
+ };
66
+
67
+ // 注册须先于下方动态 import 被测模块;bun test --isolate 下各测试文件
68
+ // 独立实例化本模块,mock 随之按文件各自生效
69
+ mock.module("../installer/proc.ts", () => ({
70
+ spawnBun: spawnBunMock,
71
+ }));
72
+
73
+ import type { Entry } from "@koishi-ce/console";
74
+ // 均为 type-only 导入:编译期擦除,不干扰 mock.module 先于插件加载的时序
75
+ import type { Plugin } from "@koishi-ce/koishi";
76
+
77
+ const { Console } = await import("@koishi-ce/console");
78
+ const { App, Logger, Service } = await import(
79
+ "@koishi-ce/koishi"
80
+ );
81
+ const http = (await import("@koishi-ce/plugin-http"))
82
+ .default;
83
+ const market = await import("../index.ts");
84
+ const mockPlugin = (await import("@koishi-ce/plugin-mock"))
85
+ .default;
86
+ // 加载包入口占位文件(纯 re-export,无独立逻辑),保证 src 全量被加载
87
+ await import("../../index.ts");
88
+
89
+ // 供各主题测试文件复用的共享构建块(App / http / Logger / market / memory)
90
+ export { App, http, Logger, market, memory };
91
+
92
+ /** 控制台服务桩:仅实现入口登记所需的最小面。 */
93
+ export class FakeConsole extends Console {
94
+ protected resolveEntry(
95
+ _files: Entry.Files,
96
+ _key: string,
97
+ ): string[] {
98
+ return [];
99
+ }
100
+ }
101
+
102
+ /** loader 服务桩(immediate Service):writable 可写、envData 供重启消息断言。 */
103
+ export class FakeLoader extends Service {
104
+ writable = true;
105
+ envData: Record<string, unknown> = { message: null };
106
+ paths() {
107
+ return ["group:entry", "plugins"];
108
+ }
109
+ fullReload() {}
110
+ constructor(
111
+ ctx: ConstructorParameters<typeof Service>[0],
112
+ ) {
113
+ super(ctx, "loader", true);
114
+ }
115
+ }
116
+
117
+ /** registry 协议最小 JSON 服务(Bun.serve,随机端口)。 */
118
+ export const registryData: {
119
+ [key: string]: {
120
+ versions: Record<
121
+ string,
122
+ {
123
+ version: string;
124
+ peerDependencies?: Record<string, string>;
125
+ deprecated?: boolean;
126
+ }
127
+ >;
128
+ time?: Record<string, string>;
129
+ };
130
+ } = {};
131
+
132
+ /** 搜索接口响应(/-/v1/search,collect 阶段消费)。初始给空结果,避免启动期空转 404。 */
133
+ let searchResponse: {
134
+ objects: unknown[];
135
+ total: number;
136
+ } | null = {
137
+ objects: [],
138
+ total: 0,
139
+ };
140
+
141
+ /** 用例经本 setter 切换搜索接口响应(let 绑定无法跨模块直接赋值)。 */
142
+ export function setSearchResponse(
143
+ value: typeof searchResponse,
144
+ ) {
145
+ searchResponse = value;
146
+ }
147
+
148
+ /** flaky-index 镜像索引桩:已发生的请求计数与计划内的 404 次数。 */
149
+ export let flakyIndexHits = 0;
150
+ let flakyIndexMisses = 0;
151
+ /** slow-index 慢响应桩:已发生的请求计数。 */
152
+ export let slowIndexHits = 0;
153
+
154
+ /** 计数导出为 live binding 供用例断言读取;写回仍须经以下 setter
155
+ * (let 绑定无法跨模块直接赋值)。 */
156
+ export function setFlakyIndexHits(hits: number) {
157
+ flakyIndexHits = hits;
158
+ }
159
+
160
+ export function setFlakyIndexMisses(misses: number) {
161
+ flakyIndexMisses = misses;
162
+ }
163
+
164
+ export function setSlowIndexHits(hits: number) {
165
+ slowIndexHits = hits;
166
+ }
167
+
168
+ /** 声明预期触发 market 域告警(404 / 限流等桩失败路径)的用例:执行期间静默该域。 */
169
+ export const itQuiet = (
170
+ name: string,
171
+ fn: () => Promise<void> | void,
172
+ timeout?: number,
173
+ ) =>
174
+ it(
175
+ name,
176
+ async () => {
177
+ const levels = Logger.levels as Record<
178
+ string,
179
+ number
180
+ >;
181
+ levels["market"] = 0;
182
+ try {
183
+ await fn();
184
+ } finally {
185
+ delete levels["market"];
186
+ }
187
+ },
188
+ timeout,
189
+ );
190
+
191
+ // 预置一个兼容插件包:最新版 2.0.0,旧版 1.0.0 均声明 koishi ^4 peer
192
+ registryData["koishi-plugin-demo"] = {
193
+ versions: {
194
+ "1.0.0": {
195
+ version: "1.0.0",
196
+ peerDependencies: { koishi: "^4.17.0" },
197
+ },
198
+ "2.0.0": {
199
+ version: "2.0.0",
200
+ peerDependencies: { koishi: "^4.17.0" },
201
+ },
202
+ },
203
+ time: {
204
+ "1.0.0": "2024-01-01T00:00:00Z",
205
+ "2.0.0": "2024-06-01T00:00:00Z",
206
+ },
207
+ };
208
+ // 预置一个未安装的插件包(plugin.install 安装路径使用)
209
+ registryData["koishi-plugin-newpkg"] = {
210
+ versions: {
211
+ "1.0.0": {
212
+ version: "1.0.0",
213
+ peerDependencies: { koishi: "^4.17.0" },
214
+ },
215
+ },
216
+ time: { "1.0.0": "2024-01-01T00:00:00Z" },
217
+ };
218
+ // 预置初始清单里三个非插件依赖的远端条目:Installer.refresh() 每轮都会对全部
219
+ // 清单依赖发起 registry 查询,缺桩会让每个用例都刷 404 告警
220
+ registryData["koishi"] = {
221
+ versions: { "4.18.0": { version: "4.18.0" } },
222
+ time: { "4.18.0": "2024-01-01T00:00:00Z" },
223
+ };
224
+ registryData["market-alias"] = {
225
+ versions: { "1.0.0": { version: "1.0.0" } },
226
+ time: { "1.0.0": "2024-01-01T00:00:00Z" },
227
+ };
228
+ registryData["bad-range"] = {
229
+ versions: { "1.0.0": { version: "1.0.0" } },
230
+ time: { "1.0.0": "2024-01-01T00:00:00Z" },
231
+ };
232
+
233
+ /** 临时宿主目录的初始依赖清单(每次重建时原样写入 package.json)。 */
234
+ const initialDependencies = {
235
+ // 护栏:workspace 声明不可被覆盖或删除
236
+ koishi: "workspace:*",
237
+ // 护栏:npm:@koishi-ce alias 同样受保护
238
+ "market-alias": "npm:@koishi-ce/anything@^1.0.0",
239
+ // 普通依赖:可解析远端最新版
240
+ "koishi-plugin-demo": "^1.0.0",
241
+ // 非法 semver 区间:应标记 invalid
242
+ "bad-range": "not-a-version",
243
+ };
244
+
245
+ /** 模块加载时的原始 cwd,stopApp 还原用。 */
246
+ const originalCwd = process.cwd();
247
+
248
+ // App 经动态 import 取值为 const,实例类型由构造器派生供 Plugin.Constructor 泛型使用
249
+ export type TestApp = InstanceType<typeof App>;
250
+
251
+ /** 临时宿主目录(Installer 的 cwd 与 override 写盘目标)。 */
252
+ export let tmp: string;
253
+ export let registryServer: Bun.Server<undefined>;
254
+ export let app: TestApp;
255
+ export let client: ReturnType<TestApp["mock"]["client"]>;
256
+
257
+ /**
258
+ * 全量重建临时宿主与配套桩(fetch 闭包仍读上方模块级 let 桩状态)。
259
+ * 裸跑(bun test 无 --isolate)时多测试文件共享本模块单例,stopApp
260
+ * 清理后由下一次 startApp 全量重建,避免对已停机实例二次 start。
261
+ */
262
+ function rebuild() {
263
+ tmp = mkdtempSync(join(tmpdir(), "market-test-"));
264
+ writeFileSync(
265
+ join(tmp, "package.json"),
266
+ JSON.stringify(
267
+ {
268
+ name: "market-host",
269
+ dependencies: initialDependencies,
270
+ },
271
+ null,
272
+ "\t",
273
+ ),
274
+ );
275
+ process.chdir(tmp);
276
+ // registry 协议最小 JSON 服务(Bun.serve,随机端口)
277
+ registryServer = Bun.serve({
278
+ port: 0,
279
+ hostname: "127.0.0.1",
280
+ async fetch(request) {
281
+ const url = new URL(request.url);
282
+ if (url.pathname.startsWith("/-/v1/search")) {
283
+ if (!searchResponse) {
284
+ return new Response("not found", {
285
+ status: 404,
286
+ });
287
+ }
288
+ return Response.json(searchResponse);
289
+ }
290
+ // 模拟镜像索引的部署窗口:前 flakyIndexMisses 次 404,随后返回完整索引
291
+ if (url.pathname === "/flaky-index") {
292
+ if (flakyIndexHits++ < flakyIndexMisses) {
293
+ return new Response("not found", {
294
+ status: 404,
295
+ });
296
+ }
297
+ return Response.json({
298
+ version: "4",
299
+ objects: [
300
+ {
301
+ package: {
302
+ name: "koishi-plugin-demo",
303
+ version: "2.0.0",
304
+ date: "2024-06-01T00:00:00Z",
305
+ keywords: ["koishi", "plugin"],
306
+ },
307
+ },
308
+ ],
309
+ });
310
+ }
311
+ // 模拟镜像索引挂起:延迟超过用例配置的 timeout,驱动超时重试路径
312
+ if (url.pathname === "/slow-index") {
313
+ slowIndexHits++;
314
+ await new Promise((resolve) =>
315
+ setTimeout(resolve, 300),
316
+ );
317
+ return Response.json({ version: "4", objects: [] });
318
+ }
319
+ // 模拟 registry 限流:恒定 429 + 极短的 Retry-After,驱动重试后失败
320
+ if (
321
+ url.pathname.includes("koishi-plugin-ratelimited")
322
+ ) {
323
+ return new Response("rate limited", {
324
+ status: 429,
325
+ headers: { "Retry-After": "0.01" },
326
+ });
327
+ }
328
+ const name = decodeURIComponent(
329
+ url.pathname.slice(1),
330
+ );
331
+ const data = registryData[name];
332
+ if (!data) {
333
+ return new Response("not found", { status: 404 });
334
+ }
335
+ return Response.json(data);
336
+ },
337
+ });
338
+ app = new App();
339
+ // MemoryDriver 的 Schema Config 推导在 TS7 增量编译下重载解析失败
340
+ // (全量编译不报,删 buildinfo 后消失),参照 FakeConsole 先例做
341
+ // 类型层转型绕开推导;Plugin.Object<App> 形态见 actions 测试先例
342
+ app.plugin(memory as unknown as Plugin.Object<TestApp>);
343
+ app.plugin(http);
344
+ // Console 基类的 static inject 是 cordis 3 旧形态,与 Plugin.Constructor 期待类型不兼容,仅做类型层转型
345
+ app.plugin(
346
+ FakeConsole as unknown as Plugin.Constructor<TestApp>,
347
+ );
348
+ app.plugin(FakeLoader);
349
+ app.plugin(market, {
350
+ registry: {
351
+ endpoint: `http://127.0.0.1:${registryServer.port}/`,
352
+ },
353
+ });
354
+ app.plugin(mockPlugin);
355
+ client = app.mock.client("123");
356
+ }
357
+
358
+ /** 各主题测试文件的 beforeAll 钩子体:启动宿主并触发延迟服务实例化。 */
359
+ export async function startApp() {
360
+ rebuild();
361
+ await app.start();
362
+ await app.mock.initUser("123", 4);
363
+ // 触发 installer 等延迟服务的实例化
364
+ expect(app.installer).toBeDefined();
365
+ }
366
+
367
+ /**
368
+ * 各主题测试文件的 afterAll 钩子体:停机、还原 cwd、清理临时目录与
369
+ * registry 桩。停机前先冲刷 MarketProvider 的节流广播(窗口 500ms):
370
+ * 悬挂的 flushData 若在 console 服务析构后才触发,裸跑(无 --isolate)
371
+ * 多文件形态下会在下一文件的执行窗口炸出 ctx.console undefined 的
372
+ * unhandled error(isolate 形态每文件独立进程环境则不可见)。
373
+ */
374
+ export async function stopApp() {
375
+ await Bun.sleep(600);
376
+ await app.stop();
377
+ process.chdir(originalCwd);
378
+ rmSync(tmp, { recursive: true, force: true });
379
+ registryServer.stop(true);
380
+ }
@@ -0,0 +1,340 @@
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
+ import {
6
+ afterAll,
7
+ beforeAll,
8
+ describe,
9
+ expect,
10
+ it,
11
+ } from "bun:test";
12
+ import { rmSync, writeFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import type { RemotePackage } from "@koishi-ce/registry";
15
+ import {
16
+ App,
17
+ app,
18
+ http,
19
+ itQuiet,
20
+ registryServer,
21
+ setSearchResponse,
22
+ startApp,
23
+ stopApp,
24
+ tmp,
25
+ } from "./helpers.ts";
26
+
27
+ const { default: Installer } = await import(
28
+ "../installer/index.ts"
29
+ );
30
+
31
+ beforeAll(startApp);
32
+ afterAll(stopApp);
33
+
34
+ describe("market 插件", () => {
35
+ it("注册三个数据服务与浏览器监听器", () => {
36
+ expect(
37
+ app.get("console.services.market"),
38
+ ).toBeDefined();
39
+ expect(
40
+ app.get("console.services.dependencies"),
41
+ ).toBeDefined();
42
+ expect(
43
+ app.get("console.services.registry"),
44
+ ).toBeDefined();
45
+ expect(
46
+ app.console.listeners["market/install"],
47
+ ).toBeDefined();
48
+ expect(
49
+ app.console.listeners["market/registry"],
50
+ ).toBeDefined();
51
+ });
52
+
53
+ it("resolveName 解析插件短名的候选全名", () => {
54
+ const installer = app.installer;
55
+ expect(
56
+ installer.resolveName("@koishijs/plugin-echo"),
57
+ ).toEqual(["@koishijs/plugin-echo"]);
58
+ expect(
59
+ installer.resolveName("koishi-plugin-echo"),
60
+ ).toEqual(["koishi-plugin-echo"]);
61
+ expect(installer.resolveName("@scope/echo")).toEqual([
62
+ "@scope/koishi-plugin-echo",
63
+ ]);
64
+ expect(installer.resolveName("echo")).toEqual([
65
+ "@koishijs/plugin-echo",
66
+ "koishi-plugin-echo",
67
+ ]);
68
+ });
69
+
70
+ it("getDeps 汇总本地依赖并带出远端最新版", async () => {
71
+ const deps = await app.installer.getDeps();
72
+ // 语义化区间去除前缀符号
73
+ expect(deps["koishi-plugin-demo"]?.request).toBe(
74
+ "1.0.0",
75
+ );
76
+ // 远端最新版(本地 registry 预置 2.0.0)
77
+ expect(deps["koishi-plugin-demo"]?.latest).toBe(
78
+ "2.0.0",
79
+ );
80
+ // 非法 semver 标记 invalid
81
+ expect(deps["bad-range"]?.invalid).toBe(true);
82
+ });
83
+
84
+ itQuiet(
85
+ "findVersion 返回首个存在的候选包版本",
86
+ async () => {
87
+ const found = await app.installer.findVersion([
88
+ "@koishijs/plugin-none",
89
+ "koishi-plugin-demo",
90
+ ]);
91
+ expect(found).toEqual({
92
+ "koishi-plugin-demo": "2.0.0",
93
+ });
94
+ // 全部不存在时返回 undefined
95
+ expect(
96
+ await app.installer.findVersion([
97
+ "@koishijs/plugin-none",
98
+ ]),
99
+ ).toBeUndefined();
100
+ },
101
+ );
102
+
103
+ itQuiet("getPackage 拉取失败时回退为空表", async () => {
104
+ const versions = await app.installer.getPackage(
105
+ "koishi-plugin-missing",
106
+ );
107
+ expect(versions).toEqual({});
108
+ });
109
+
110
+ it("setPackage 写入缓存并触发节流广播", async () => {
111
+ app.installer.setPackage("koishi-plugin-demo", [
112
+ {
113
+ version: "3.0.0",
114
+ peerDependencies: { koishi: "^4.17.0" },
115
+ // RemotePackage 的其余元数据字段与本断言无关,最小载荷经 unknown 二段式断言
116
+ } as unknown as RemotePackage,
117
+ ]);
118
+ expect(
119
+ Object.keys(
120
+ app.installer.fullCache["koishi-plugin-demo"] ?? {},
121
+ ),
122
+ ).toEqual(["3.0.0"]);
123
+ // 等待节流窗口
124
+ await new Promise((resolve) =>
125
+ setTimeout(resolve, 600),
126
+ );
127
+ });
128
+ });
129
+
130
+ describe("registry 配置探测", () => {
131
+ it("无显式 endpoint 时按 npmrc / 环境变量探测", async () => {
132
+ // 环境变量优先:npm_config_registry 指向本地服务
133
+ process.env["npm_config_registry"] =
134
+ `http://127.0.0.1:${registryServer.port}/`;
135
+ const app2 = new App();
136
+ app2.plugin(http);
137
+ app2.plugin(Installer, {});
138
+ await app2.start();
139
+ expect(app2.installer.endpoint).toBe(
140
+ `http://127.0.0.1:${registryServer.port}/`,
141
+ );
142
+ await app2.stop();
143
+
144
+ // 环境变量缺失时回落到项目 .npmrc(含不合法行与合法 registry 行)
145
+ delete process.env["npm_config_registry"];
146
+ writeFileSync(
147
+ join(tmp, ".npmrc"),
148
+ "not-a-registry-line\nregistry=http://registry.example.npm/\n",
149
+ );
150
+ const app3 = new App();
151
+ app3.plugin(http);
152
+ app3.plugin(Installer, {});
153
+ await app3.start();
154
+ expect(app3.installer.endpoint).toBe(
155
+ "http://registry.example.npm/",
156
+ );
157
+ await app3.stop();
158
+ rmSync(join(tmp, ".npmrc"), { force: true });
159
+ });
160
+ });
161
+
162
+ describe("MarketProvider 市场数据服务", () => {
163
+ it("collect 经搜索接口收集并逐包分析填充缓存", async () => {
164
+ const svc = app.get("console.services.market");
165
+ expect(svc).toBeDefined();
166
+ // 提供搜索结果:一个插件条目 + 一个被忽略条目
167
+ setSearchResponse({
168
+ objects: [
169
+ {
170
+ package: {
171
+ name: "koishi-plugin-demo",
172
+ version: "1.0.0",
173
+ date: "2024-01-01T00:00:00Z",
174
+ keywords: ["koishi", "plugin", "Tool"],
175
+ },
176
+ },
177
+ {
178
+ package: {
179
+ name: "not-a-plugin",
180
+ date: "2024-01-01T00:00:00Z",
181
+ },
182
+ },
183
+ ],
184
+ total: 1,
185
+ });
186
+ // start(true) 强制刷新市场数据(重新 collect)
187
+ await svc?.start(true);
188
+ // 等待节流窗口与逐包分析完成
189
+ await new Promise((resolve) =>
190
+ setTimeout(resolve, 700),
191
+ );
192
+ const payload = await svc?.get();
193
+ expect(payload).toBeDefined();
194
+ // 非 plugin 条目被剔除,只保留 demo
195
+ expect(Object.keys(payload?.data ?? {})).toEqual([
196
+ "koishi-plugin-demo",
197
+ ]);
198
+ expect(payload?.total).toBe(1);
199
+ expect(payload?.failed).toBe(0);
200
+ expect(payload?.registry).toBe(
201
+ `http://127.0.0.1:${registryServer.port}/`,
202
+ );
203
+ });
204
+
205
+ it("依赖服务两拍推送:快照先行、元数据完成后填充", async () => {
206
+ const service = app.get(
207
+ "console.services.dependencies",
208
+ );
209
+ expect(service).toBeDefined();
210
+ const first = await service?.get();
211
+ // 第一拍:本地快照先行,元数据尚在拉取
212
+ expect(
213
+ first?.["koishi-plugin-demo"]?.request,
214
+ ).toBeTruthy();
215
+ // 等元数据拉取完成(本地 registry 桩,秒级内):条目就地填充
216
+ await new Promise((resolve) =>
217
+ setTimeout(resolve, 800),
218
+ );
219
+ expect(first?.["koishi-plugin-demo"]?.latest).toBe(
220
+ "2.0.0",
221
+ );
222
+ expect(
223
+ first?.["koishi-plugin-demo"]?.fetching,
224
+ ).toBeUndefined();
225
+ // 第二拍 get() 复用上一轮结果,不再拉取
226
+ const second = await service?.get();
227
+ expect(second?.["koishi-plugin-demo"]?.latest).toBe(
228
+ "2.0.0",
229
+ );
230
+
231
+ const registry = await app
232
+ .get("console.services.registry")
233
+ ?.get();
234
+ expect(
235
+ Object.keys(registry?.["koishi-plugin-demo"] ?? {}),
236
+ ).toContain("2.0.0");
237
+ });
238
+
239
+ itQuiet(
240
+ "registry 无此包的条目两拍后标记 not-found 负缓存",
241
+ async () => {
242
+ // 向宿主清单添加一个 registry 桩里不存在的依赖
243
+ const manifestPath = join(tmp, "package.json");
244
+ const manifest = JSON.parse(
245
+ await Bun.file(manifestPath).text(),
246
+ ) as {
247
+ dependencies: Record<string, string>;
248
+ };
249
+ manifest.dependencies["koishi-plugin-ghost"] =
250
+ "^1.0.0";
251
+ await Bun.write(
252
+ manifestPath,
253
+ JSON.stringify(manifest, null, "\t"),
254
+ );
255
+ try {
256
+ const service = app.get(
257
+ "console.services.dependencies",
258
+ );
259
+ const snapshot = await service?.get();
260
+ expect(
261
+ snapshot?.["koishi-plugin-ghost"]?.request,
262
+ ).toBe("1.0.0");
263
+ await new Promise((resolve) =>
264
+ setTimeout(resolve, 800),
265
+ );
266
+ expect(
267
+ snapshot?.["koishi-plugin-ghost"]?.error,
268
+ ).toBe("not-found");
269
+ // 404 负缓存:重建快照仍标记 not-found 且不再拉取
270
+ const again = await service?.get();
271
+ expect(again?.["koishi-plugin-ghost"]?.error).toBe(
272
+ "not-found",
273
+ );
274
+ expect(
275
+ again?.["koishi-plugin-ghost"]?.fetching,
276
+ ).toBeUndefined();
277
+ } finally {
278
+ delete manifest.dependencies["koishi-plugin-ghost"];
279
+ await Bun.write(
280
+ manifestPath,
281
+ JSON.stringify(manifest, null, "\t"),
282
+ );
283
+ }
284
+ },
285
+ 15000,
286
+ );
287
+
288
+ itQuiet(
289
+ "搜索接口失败时 get 返回空数据与错误标记",
290
+ async () => {
291
+ const svc = app.get("console.services.market");
292
+ setSearchResponse(null);
293
+ // 强制重扫:collect 失败置 _error,get 返回空 payload
294
+ await svc?.start(true);
295
+ const payload = await svc?.get();
296
+ expect(payload).toEqual({
297
+ data: {},
298
+ failed: 0,
299
+ total: 0,
300
+ progress: 0,
301
+ });
302
+ setSearchResponse({
303
+ objects: [],
304
+ total: 0,
305
+ });
306
+ },
307
+ );
308
+
309
+ it("控制台连接事件在数据过期时触发刷新", async () => {
310
+ const svc = app.get("console.services.market");
311
+ expect(svc).toBeDefined();
312
+ // 伪造一个在线客户端,使连接事件通过在线检查(broadcast 需可用的 socket)
313
+ const fakeClient = {
314
+ id: "conn-1",
315
+ socket: { send() {} },
316
+ };
317
+ (app.console.clients as Record<string, unknown>)[
318
+ "conn-1"
319
+ ] = fakeClient;
320
+ // 刚刷新过:12 小时窗口内直接返回,不重新收集
321
+ const timestamp = svc?.[
322
+ "_timestamp" as keyof typeof svc
323
+ ] as number;
324
+ // console/connection 载荷声明为 Client,桩对象仅含在线检查所需的最小面,类型层断言穿透
325
+ app.emit("console/connection", fakeClient as never);
326
+ expect(
327
+ svc?.["_timestamp" as keyof typeof svc] as number,
328
+ ).toBe(timestamp);
329
+ // 将时间戳回拨到窗口外,连接事件重新触发 start(异步监听,稍等)
330
+ (svc as Record<string, unknown>)["_timestamp"] = 0;
331
+ app.emit("console/connection", fakeClient as never);
332
+ await new Promise((resolve) => setTimeout(resolve, 20));
333
+ expect(
334
+ (svc as Record<string, unknown>)[
335
+ "_timestamp"
336
+ ] as number,
337
+ ).toBeGreaterThan(0);
338
+ delete app.console.clients["conn-1"];
339
+ });
340
+ });