@koishi-ce/plugin-market 1.1.3 → 1.1.4

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("../node/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("../node/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,255 @@
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 { mkdirSync, rmSync, writeFileSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import {
15
+ app,
16
+ itQuiet,
17
+ setNextExitCode,
18
+ setNextSpawnError,
19
+ spawnCalls,
20
+ startApp,
21
+ stopApp,
22
+ tmp,
23
+ } from "./helpers.ts";
24
+
25
+ const { isResidentInCache } = await import(
26
+ "@koishi-ce/registry"
27
+ );
28
+
29
+ beforeAll(startApp);
30
+ afterAll(stopApp);
31
+
32
+ describe("Installer 安装链路", () => {
33
+ itQuiet(
34
+ "install 尊重护栏依赖并执行子进程安装",
35
+ async () => {
36
+ setNextExitCode(0);
37
+ spawnCalls.length = 0;
38
+ const code = await app.installer.install({
39
+ koishi: "2.0.0",
40
+ "market-alias": null,
41
+ "koishi-plugin-demo": "^1.0.0",
42
+ });
43
+ expect(code).toBe(0);
44
+ // 触发了包管理器安装(bun install --registry …)
45
+ expect(spawnCalls.length).toBe(1);
46
+ expect(spawnCalls[0]?.[0]).toBe("install");
47
+ // 重新读取临时 package.json:护栏项保持原样,新依赖加入
48
+ const manifest = JSON.parse(
49
+ await Bun.file(join(tmp, "package.json")).text(),
50
+ ) as { dependencies: Record<string, string> };
51
+ expect(manifest.dependencies["koishi"]).toBe(
52
+ "workspace:*",
53
+ );
54
+ expect(manifest.dependencies["market-alias"]).toBe(
55
+ "npm:@koishi-ce/anything@^1.0.0",
56
+ );
57
+ expect(
58
+ manifest.dependencies["koishi-plugin-demo"],
59
+ ).toBe("^1.0.0");
60
+ },
61
+ 15000,
62
+ );
63
+
64
+ itQuiet(
65
+ "install 强制时无视本地满足也要装",
66
+ async () => {
67
+ setNextExitCode(0);
68
+ spawnCalls.length = 0;
69
+ const code = await app.installer.install(
70
+ { "koishi-plugin-demo": "^1.0.0" },
71
+ true,
72
+ );
73
+ expect(code).toBe(0);
74
+ expect(spawnCalls.length).toBe(1);
75
+ },
76
+ 15000,
77
+ );
78
+
79
+ itQuiet(
80
+ "子进程非零退出码向上传递",
81
+ async () => {
82
+ setNextExitCode(1);
83
+ spawnCalls.length = 0;
84
+ const code = await app.installer.install(
85
+ { "koishi-plugin-demo": "^2.0.0" },
86
+ true,
87
+ );
88
+ expect(code).toBe(1);
89
+ },
90
+ 15000,
91
+ );
92
+
93
+ itQuiet("子进程 spawn 失败返回 -1", async () => {
94
+ setNextSpawnError(true);
95
+ setNextExitCode(0);
96
+ const code = await app.installer.exec(["install"]);
97
+ setNextSpawnError(false);
98
+ expect(code).toBe(-1);
99
+ });
100
+
101
+ /** 在临时 node_modules 落盘一个带 dependencies 声明的校验用包 */
102
+ function placeLinkcheckPkg(
103
+ dependencies: Record<string, string>,
104
+ ) {
105
+ const name = "koishi-plugin-linkcheck";
106
+ const pkgDir = join(tmp, "node_modules", name);
107
+ mkdirSync(pkgDir, { recursive: true });
108
+ writeFileSync(
109
+ join(pkgDir, "package.json"),
110
+ JSON.stringify({
111
+ name,
112
+ version: "1.0.0",
113
+ dependencies,
114
+ }),
115
+ );
116
+ return pkgDir;
117
+ }
118
+
119
+ itQuiet(
120
+ "装后依赖链接完整时不触发补装",
121
+ async () => {
122
+ setNextExitCode(0);
123
+ spawnCalls.length = 0;
124
+ const pkgDir = placeLinkcheckPkg({
125
+ "linkcheck-dep": "^1.0.0",
126
+ });
127
+ const depDir = join(
128
+ tmp,
129
+ "node_modules",
130
+ "linkcheck-dep",
131
+ );
132
+ mkdirSync(depDir, { recursive: true });
133
+ writeFileSync(
134
+ join(depDir, "package.json"),
135
+ JSON.stringify({
136
+ name: "linkcheck-dep",
137
+ version: "1.0.0",
138
+ }),
139
+ );
140
+ try {
141
+ const code = await app.installer.install(
142
+ { "koishi-plugin-linkcheck": "^1.0.0" },
143
+ true,
144
+ );
145
+ expect(code).toBe(0);
146
+ expect(spawnCalls.length).toBe(1);
147
+ } finally {
148
+ rmSync(pkgDir, { recursive: true, force: true });
149
+ rmSync(depDir, { recursive: true, force: true });
150
+ }
151
+ },
152
+ 15000,
153
+ );
154
+
155
+ itQuiet(
156
+ "包级依赖链接缺失时自动补装一次且不无限重试",
157
+ async () => {
158
+ setNextExitCode(0);
159
+ spawnCalls.length = 0;
160
+ // isolated 增量安装缺陷形态:包已落盘但声明的依赖未链接。
161
+ // 首装后校验命中补装一次;mock 安装不会真落盘依赖,二次校验
162
+ // 仍缺,只告警不再补装——总计恰好两次子进程调用
163
+ const pkgDir = placeLinkcheckPkg({
164
+ "linkcheck-missing": "^1.0.0",
165
+ });
166
+ try {
167
+ const code = await app.installer.install(
168
+ { "koishi-plugin-linkcheck": "^1.0.0" },
169
+ true,
170
+ );
171
+ expect(code).toBe(0);
172
+ expect(spawnCalls.length).toBe(2);
173
+ } finally {
174
+ rmSync(pkgDir, { recursive: true, force: true });
175
+ }
176
+ },
177
+ 15000,
178
+ );
179
+
180
+ itQuiet(
181
+ "alias 声明的依赖跳过链接校验",
182
+ async () => {
183
+ setNextExitCode(0);
184
+ spawnCalls.length = 0;
185
+ // alias 落盘名与键名无直接映射,不入校验,缺失也不触发补装
186
+ const pkgDir = placeLinkcheckPkg({
187
+ "linkcheck-alias": "npm:whatever@^1.0.0",
188
+ });
189
+ try {
190
+ const code = await app.installer.install(
191
+ { "koishi-plugin-linkcheck": "^1.0.0" },
192
+ true,
193
+ );
194
+ expect(code).toBe(0);
195
+ expect(spawnCalls.length).toBe(1);
196
+ } finally {
197
+ rmSync(pkgDir, { recursive: true, force: true });
198
+ }
199
+ },
200
+ 15000,
201
+ );
202
+
203
+ itQuiet(
204
+ "exec 收集子进程 stdout / stderr 输出行",
205
+ async () => {
206
+ setNextExitCode(0);
207
+ setNextSpawnError(false);
208
+ const code = await app.installer.exec(["install"]);
209
+ expect(code).toBe(0);
210
+ },
211
+ );
212
+ });
213
+
214
+ describe("isResidentInCache(装后重载判定)", () => {
215
+ /** 在临时 node_modules 放一个已安装包,返回其目录 */
216
+ function placePkg(name: string) {
217
+ const pkgDir = join(tmp, "node_modules", name);
218
+ mkdirSync(pkgDir, { recursive: true });
219
+ writeFileSync(
220
+ join(pkgDir, "package.json"),
221
+ JSON.stringify({
222
+ name,
223
+ version: "1.0.0",
224
+ main: "index.js",
225
+ }),
226
+ );
227
+ writeFileSync(
228
+ join(pkgDir, "index.js"),
229
+ "module.exports = {}",
230
+ );
231
+ return pkgDir;
232
+ }
233
+
234
+ it("包目录下有模块驻留 require.cache 时返回 true", () => {
235
+ const pkgDir = placePkg("koishi-plugin-resident-check");
236
+ // 入口经 require 进入 require.cache,模拟旧版本驻留内存
237
+ require(join(pkgDir, "index.js"));
238
+ expect(
239
+ isResidentInCache("koishi-plugin-resident-check"),
240
+ ).toBe(true);
241
+ });
242
+
243
+ it("已安装但无模块驻留内存时返回 false", () => {
244
+ placePkg("koishi-plugin-idle-check");
245
+ expect(
246
+ isResidentInCache("koishi-plugin-idle-check"),
247
+ ).toBe(false);
248
+ });
249
+
250
+ it("包不存在时保守返回 true(宁可多重载不漏判)", () => {
251
+ expect(
252
+ isResidentInCache("koishi-plugin-absent-check"),
253
+ ).toBe(true);
254
+ });
255
+ });