@koishi-ce/plugin-market 1.1.2 → 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.
- package/dist/index.js +2 -2
- package/dist/style.css +1 -1
- package/package.json +4 -4
- package/src/__tests__/commands.test.ts +349 -0
- package/src/__tests__/helpers.ts +380 -0
- package/src/__tests__/installer.test.ts +255 -0
- package/src/__tests__/provider.test.ts +276 -0
- package/src/__tests__/retry.test.ts +169 -0
- package/src/index.test.ts +0 -1242
|
@@ -0,0 +1,276 @@
|
|
|
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
|
+
"../node/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 dependencies = await app
|
|
207
|
+
.get("console.services.dependencies")
|
|
208
|
+
?.get();
|
|
209
|
+
expect(
|
|
210
|
+
dependencies?.["koishi-plugin-demo"]?.request,
|
|
211
|
+
).toBeTruthy();
|
|
212
|
+
expect(
|
|
213
|
+
dependencies?.["koishi-plugin-demo"]?.latest,
|
|
214
|
+
).toBe("2.0.0");
|
|
215
|
+
|
|
216
|
+
const registry = await app
|
|
217
|
+
.get("console.services.registry")
|
|
218
|
+
?.get();
|
|
219
|
+
expect(
|
|
220
|
+
Object.keys(registry?.["koishi-plugin-demo"] ?? {}),
|
|
221
|
+
).toContain("2.0.0");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
itQuiet(
|
|
225
|
+
"搜索接口失败时 get 返回空数据与错误标记",
|
|
226
|
+
async () => {
|
|
227
|
+
const svc = app.get("console.services.market");
|
|
228
|
+
setSearchResponse(null);
|
|
229
|
+
// 强制重扫:collect 失败置 _error,get 返回空 payload
|
|
230
|
+
await svc?.start(true);
|
|
231
|
+
const payload = await svc?.get();
|
|
232
|
+
expect(payload).toEqual({
|
|
233
|
+
data: {},
|
|
234
|
+
failed: 0,
|
|
235
|
+
total: 0,
|
|
236
|
+
progress: 0,
|
|
237
|
+
});
|
|
238
|
+
setSearchResponse({
|
|
239
|
+
objects: [],
|
|
240
|
+
total: 0,
|
|
241
|
+
});
|
|
242
|
+
},
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
it("控制台连接事件在数据过期时触发刷新", async () => {
|
|
246
|
+
const svc = app.get("console.services.market");
|
|
247
|
+
expect(svc).toBeDefined();
|
|
248
|
+
// 伪造一个在线客户端,使连接事件通过在线检查(broadcast 需可用的 socket)
|
|
249
|
+
const fakeClient = {
|
|
250
|
+
id: "conn-1",
|
|
251
|
+
socket: { send() {} },
|
|
252
|
+
};
|
|
253
|
+
(app.console.clients as Record<string, unknown>)[
|
|
254
|
+
"conn-1"
|
|
255
|
+
] = fakeClient;
|
|
256
|
+
// 刚刷新过:12 小时窗口内直接返回,不重新收集
|
|
257
|
+
const timestamp = svc?.[
|
|
258
|
+
"_timestamp" as keyof typeof svc
|
|
259
|
+
] as number;
|
|
260
|
+
// console/connection 载荷声明为 Client,桩对象仅含在线检查所需的最小面,类型层断言穿透
|
|
261
|
+
app.emit("console/connection", fakeClient as never);
|
|
262
|
+
expect(
|
|
263
|
+
svc?.["_timestamp" as keyof typeof svc] as number,
|
|
264
|
+
).toBe(timestamp);
|
|
265
|
+
// 将时间戳回拨到窗口外,连接事件重新触发 start(异步监听,稍等)
|
|
266
|
+
(svc as Record<string, unknown>)["_timestamp"] = 0;
|
|
267
|
+
app.emit("console/connection", fakeClient as never);
|
|
268
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
269
|
+
expect(
|
|
270
|
+
(svc as Record<string, unknown>)[
|
|
271
|
+
"_timestamp"
|
|
272
|
+
] as number,
|
|
273
|
+
).toBeGreaterThan(0);
|
|
274
|
+
delete app.console.clients["conn-1"];
|
|
275
|
+
});
|
|
276
|
+
});
|
|
@@ -0,0 +1,169 @@
|
|
|
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 net from "node:net";
|
|
13
|
+
import type { Plugin } from "@koishi-ce/koishi";
|
|
14
|
+
import {
|
|
15
|
+
App,
|
|
16
|
+
FakeConsole,
|
|
17
|
+
FakeLoader,
|
|
18
|
+
flakyIndexHits,
|
|
19
|
+
http,
|
|
20
|
+
itQuiet,
|
|
21
|
+
market,
|
|
22
|
+
memory,
|
|
23
|
+
registryServer,
|
|
24
|
+
setFlakyIndexHits,
|
|
25
|
+
setFlakyIndexMisses,
|
|
26
|
+
setSlowIndexHits,
|
|
27
|
+
slowIndexHits,
|
|
28
|
+
startApp,
|
|
29
|
+
stopApp,
|
|
30
|
+
type TestApp,
|
|
31
|
+
} from "./helpers.ts";
|
|
32
|
+
|
|
33
|
+
beforeAll(startApp);
|
|
34
|
+
afterAll(stopApp);
|
|
35
|
+
|
|
36
|
+
describe("镜像索引瞬态重试", () => {
|
|
37
|
+
/** 搭建配了 search.endpoint 的独立宿主(镜像索引经本测试桩提供)。 */
|
|
38
|
+
async function createMirrorApp(search: {
|
|
39
|
+
endpoint: string;
|
|
40
|
+
timeout?: number;
|
|
41
|
+
}) {
|
|
42
|
+
const mirror = new App();
|
|
43
|
+
// 类型层转型缘由见 helpers 的 app.plugin(memory) 注释
|
|
44
|
+
mirror.plugin(
|
|
45
|
+
memory as unknown as Plugin.Object<TestApp>,
|
|
46
|
+
);
|
|
47
|
+
mirror.plugin(http);
|
|
48
|
+
mirror.plugin(
|
|
49
|
+
FakeConsole as unknown as Plugin.Constructor<TestApp>,
|
|
50
|
+
);
|
|
51
|
+
mirror.plugin(FakeLoader);
|
|
52
|
+
mirror.plugin(market, {
|
|
53
|
+
registry: {
|
|
54
|
+
endpoint: `http://127.0.0.1:${registryServer.port}/`,
|
|
55
|
+
},
|
|
56
|
+
search,
|
|
57
|
+
});
|
|
58
|
+
await mirror.start();
|
|
59
|
+
return mirror;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
it("镜像索引部署窗口 404 经退避重试后恢复", async () => {
|
|
63
|
+
setFlakyIndexHits(0);
|
|
64
|
+
setFlakyIndexMisses(2);
|
|
65
|
+
const mirror = await createMirrorApp({
|
|
66
|
+
endpoint: `http://127.0.0.1:${registryServer.port}/flaky-index`,
|
|
67
|
+
});
|
|
68
|
+
const svc = mirror.get("console.services.market");
|
|
69
|
+
// start(true) 内部 await prepare:前两次 404,退避重试后第三次成功
|
|
70
|
+
await svc?.start(true);
|
|
71
|
+
expect(flakyIndexHits).toBeGreaterThanOrEqual(3);
|
|
72
|
+
const payload = await svc?.get();
|
|
73
|
+
expect(Object.keys(payload?.data ?? {})).toEqual([
|
|
74
|
+
"koishi-plugin-demo",
|
|
75
|
+
]);
|
|
76
|
+
expect(payload?.total).toBe(1);
|
|
77
|
+
await mirror.stop();
|
|
78
|
+
}, 10000);
|
|
79
|
+
|
|
80
|
+
itQuiet(
|
|
81
|
+
"镜像索引持续超时经重试后优雅失败",
|
|
82
|
+
async () => {
|
|
83
|
+
setSlowIndexHits(0);
|
|
84
|
+
const mirror = await createMirrorApp({
|
|
85
|
+
endpoint: `http://127.0.0.1:${registryServer.port}/slow-index`,
|
|
86
|
+
timeout: 50,
|
|
87
|
+
});
|
|
88
|
+
const svc = mirror.get("console.services.market");
|
|
89
|
+
// 每次尝试 50ms 即超时,退避 1s + 2s 后仍失败;重试确实发生
|
|
90
|
+
await svc?.start(true);
|
|
91
|
+
expect(slowIndexHits).toBeGreaterThanOrEqual(3);
|
|
92
|
+
// start 末尾的 refresh 会再触发一轮 collect,等待其失败落地
|
|
93
|
+
await new Promise((resolve) =>
|
|
94
|
+
setTimeout(resolve, 3600),
|
|
95
|
+
);
|
|
96
|
+
const error = svc?.["_error" as keyof typeof svc];
|
|
97
|
+
expect(error).toBeDefined();
|
|
98
|
+
await mirror.stop();
|
|
99
|
+
},
|
|
100
|
+
15000,
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
// 裸 TCP 桩:前 planned 次请求收到请求头后直接销毁连接(复现
|
|
104
|
+
// 证书验证失败 / 连接重置等 fetch 网络层瞬态错误),随后回正常索引
|
|
105
|
+
function startFlakyTcpServer(planned: number) {
|
|
106
|
+
let hits = 0;
|
|
107
|
+
const server = net.createServer((socket) => {
|
|
108
|
+
socket.on("data", () => {
|
|
109
|
+
hits++;
|
|
110
|
+
if (hits <= planned) {
|
|
111
|
+
socket.destroy();
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const body = JSON.stringify({
|
|
115
|
+
version: "4",
|
|
116
|
+
objects: [
|
|
117
|
+
{
|
|
118
|
+
package: {
|
|
119
|
+
name: "koishi-plugin-demo",
|
|
120
|
+
version: "2.0.0",
|
|
121
|
+
date: "2024-06-01T00:00:00Z",
|
|
122
|
+
keywords: ["koishi", "plugin"],
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
});
|
|
127
|
+
socket.end(
|
|
128
|
+
`HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: ${body.length}\r\nConnection: close\r\n\r\n${body}`,
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
return new Promise<{
|
|
133
|
+
port: number;
|
|
134
|
+
hits: () => number;
|
|
135
|
+
close: () => void;
|
|
136
|
+
}>((resolve) => {
|
|
137
|
+
server.listen(0, "127.0.0.1", () => {
|
|
138
|
+
const { port } =
|
|
139
|
+
server.address() as net.AddressInfo;
|
|
140
|
+
resolve({
|
|
141
|
+
port,
|
|
142
|
+
hits: () => hits,
|
|
143
|
+
close: () => {
|
|
144
|
+
server.close();
|
|
145
|
+
},
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
it("网络层瞬态错误(连接重置)经退避重试后恢复", async () => {
|
|
152
|
+
const stub = await startFlakyTcpServer(2);
|
|
153
|
+
const mirror = await createMirrorApp({
|
|
154
|
+
endpoint: `http://127.0.0.1:${stub.port}/`,
|
|
155
|
+
});
|
|
156
|
+
const svc = mirror.get("console.services.market");
|
|
157
|
+
// 前两次连接被重置(fetch 抛 TypeError,plugin-http 包装为无 code
|
|
158
|
+
// 无 response 的 HTTP.Error),退避重试后第三次成功
|
|
159
|
+
await svc?.start(true);
|
|
160
|
+
expect(stub.hits()).toBeGreaterThanOrEqual(3);
|
|
161
|
+
const payload = await svc?.get();
|
|
162
|
+
expect(Object.keys(payload?.data ?? {})).toEqual([
|
|
163
|
+
"koishi-plugin-demo",
|
|
164
|
+
]);
|
|
165
|
+
expect(payload?.total).toBe(1);
|
|
166
|
+
await mirror.stop();
|
|
167
|
+
stub.close();
|
|
168
|
+
}, 10000);
|
|
169
|
+
});
|