@serverkgg/bridge 0.0.12 → 0.0.14
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/package.json +9 -1
- package/src/catalogs/curseforge.test.ts +580 -0
- package/src/catalogs/curseforge.ts +461 -0
- package/src/catalogs/index.ts +2 -0
- package/src/catalogs/thunderstore.test.ts +94 -0
- package/src/catalogs/thunderstore.ts +239 -0
- package/src/codecs/index.ts +3 -0
- package/src/codecs/ini.test.ts +152 -0
- package/src/codecs/ini.ts +158 -0
- package/src/codecs/sourceCfg.test.ts +100 -0
- package/src/codecs/sourceCfg.ts +111 -0
- package/src/codecs/xmlProperties.test.ts +104 -0
- package/src/codecs/xmlProperties.ts +133 -0
- package/src/context/bridgeContext.ts +27 -0
- package/src/core/bridgeRconError.ts +19 -0
- package/src/core/index.ts +1 -0
- package/src/install/index.ts +1 -0
- package/src/install/installStamp.test.ts +125 -0
- package/src/install/installStamp.ts +40 -0
- package/src/main.ts +1 -0
- package/src/manifest/index.ts +1 -0
- package/src/manifest/manifest.test.ts +427 -2
- package/src/manifest/manifest.ts +142 -14
- package/src/manifest/primitives.ts +6 -0
- package/src/manifest/runtime.ts +89 -0
- package/src/modules/bridgeModule.ts +2 -1
- package/src/net/index.ts +2 -0
- package/src/net/loopback.test.ts +152 -0
- package/src/net/loopback.ts +109 -0
- package/src/net/loopbackMethod.ts +7 -0
- package/src/presence/index.ts +1 -0
- package/src/presence/rosterSync.test.ts +179 -0
- package/src/presence/rosterSync.ts +57 -0
- package/src/protocol/bridgeEvents.ts +4 -0
- package/src/rcon/index.ts +4 -0
- package/src/rcon/rconAccess.test.ts +286 -0
- package/src/rcon/rconAccess.ts +252 -0
- package/src/rcon/rconClient.test.ts +1544 -0
- package/src/rcon/rconClient.ts +275 -0
- package/src/rcon/rconError.ts +21 -0
- package/src/rcon/rconFrame.test.ts +269 -0
- package/src/rcon/rconFrame.ts +108 -0
- package/src/rcon/rconIds.test.ts +59 -0
- package/src/rcon/rconIds.ts +33 -0
- package/src/rcon/rconLink.ts +427 -0
- package/src/rcon/rconWriter.test.ts +95 -0
- package/src/rcon/rconWriter.ts +63 -0
- package/src/runtime/bridgeCodec.ts +45 -2
- package/src/runtime/bridgeConsoleHandler.test.ts +196 -0
- package/src/runtime/bridgeConsoleHandler.ts +26 -0
- package/src/runtime/bridgeContext.ts +13 -6
- package/src/runtime/bridgeDriver.test.ts +1 -0
- package/src/runtime/bridgeLiveConfig.test.ts +149 -0
- package/src/runtime/bridgeLiveConfig.ts +63 -0
- package/src/runtime/bridgeRcon.ts +19 -0
- package/src/runtime/bridgeRegistry.ts +1 -0
- package/src/runtime/serve.ts +10 -1
- package/src/steam/index.ts +7 -0
- package/src/steam/steamApp.test.ts +78 -0
- package/src/steam/steamApp.ts +35 -0
- package/src/steam/steamcmd.test.ts +157 -0
- package/src/steam/steamcmd.ts +273 -0
- package/src/steam/steamcmdLogin.ts +34 -0
- package/src/steam/steamcmdNarrator.test.ts +90 -0
- package/src/steam/steamcmdNarrator.ts +43 -0
- package/src/steam/steamcmdPlatform.ts +4 -0
- package/src/steam/webApi.ts +70 -0
- package/src/steam/workshop.test.ts +45 -0
- package/src/steam/workshop.ts +148 -0
- package/src/utils/execDetail.ts +16 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/token.test.ts +50 -0
- package/src/utils/token.ts +35 -0
- package/src/wine/index.ts +3 -0
- package/src/wine/protonCommand.test.ts +67 -0
- package/src/wine/protonCommand.ts +49 -0
- package/src/wine/wineCommand.test.ts +92 -0
- package/src/wine/wineCommand.ts +54 -0
- package/src/wine/winePrefix.test.ts +111 -0
- package/src/wine/winePrefix.ts +88 -0
- package/src/runtime/bridgeSecrets.test.ts +0 -121
- package/src/runtime/bridgeSecrets.ts +0 -43
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@serverkgg/bridge",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.14",
|
|
4
4
|
"description": "Serverk game bridge and driver runtime, wire protocol, manifest schema, and maintainer CLI",
|
|
5
5
|
"homepage": "https://serverk.gg",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
@@ -24,6 +24,14 @@
|
|
|
24
24
|
"./blog": "./src/blog/index.ts",
|
|
25
25
|
"./probes": "./src/probes/index.ts",
|
|
26
26
|
"./wire": "./src/wire/index.ts",
|
|
27
|
+
"./catalogs": "./src/catalogs/index.ts",
|
|
28
|
+
"./install": "./src/install/index.ts",
|
|
29
|
+
"./net": "./src/net/index.ts",
|
|
30
|
+
"./presence": "./src/presence/index.ts",
|
|
31
|
+
"./rcon": "./src/rcon/index.ts",
|
|
32
|
+
"./steam": "./src/steam/index.ts",
|
|
33
|
+
"./utils": "./src/utils/index.ts",
|
|
34
|
+
"./wine": "./src/wine/index.ts",
|
|
27
35
|
"./package.json": "./package.json"
|
|
28
36
|
},
|
|
29
37
|
"scripts": {
|
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { BridgeContext, BridgeRequestOptions } from "../context";
|
|
3
|
+
import { BridgeFailureCode, BridgeFailureError, BridgeNetError, BridgeSecretError } from "../core";
|
|
4
|
+
import { BridgeHttpMethod } from "../protocol";
|
|
5
|
+
import {
|
|
6
|
+
CURSEFORGE_API,
|
|
7
|
+
CURSEFORGE_DOWNLOAD,
|
|
8
|
+
CURSEFORGE_SECRET,
|
|
9
|
+
type CurseforgeCatalogOptions,
|
|
10
|
+
type CurseforgeFile,
|
|
11
|
+
CurseforgeHash,
|
|
12
|
+
CurseforgeReleaseType,
|
|
13
|
+
CurseforgeSort,
|
|
14
|
+
CurseforgeSortOrder,
|
|
15
|
+
createCurseforgeCatalog,
|
|
16
|
+
curseforgeDownloadUrl,
|
|
17
|
+
curseforgeFallbackUrl,
|
|
18
|
+
curseforgeSha1,
|
|
19
|
+
isCurseforgeServerFile,
|
|
20
|
+
selectCurseforgeFile,
|
|
21
|
+
} from "./curseforge";
|
|
22
|
+
|
|
23
|
+
const file = (overrides: Partial<CurseforgeFile> = {}): CurseforgeFile => {
|
|
24
|
+
return {
|
|
25
|
+
id: 1,
|
|
26
|
+
modId: 10,
|
|
27
|
+
fileName: "mod.jar",
|
|
28
|
+
displayName: "mod",
|
|
29
|
+
downloadUrl: "https://edge.forgecdn.net/files/4711/12/mod.jar",
|
|
30
|
+
fileLength: 100,
|
|
31
|
+
fileDate: null,
|
|
32
|
+
isAvailable: true,
|
|
33
|
+
releaseType: CurseforgeReleaseType.Release,
|
|
34
|
+
gameVersions: [],
|
|
35
|
+
hashes: [],
|
|
36
|
+
dependencies: [],
|
|
37
|
+
...overrides,
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
describe("curseforgeSha1", () => {
|
|
42
|
+
test("finds the sha1 hash and lowercases it", () => {
|
|
43
|
+
expect(
|
|
44
|
+
curseforgeSha1(
|
|
45
|
+
file({
|
|
46
|
+
hashes: [
|
|
47
|
+
{
|
|
48
|
+
value: "AABB",
|
|
49
|
+
algo: CurseforgeHash.Sha1,
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
value: "ccdd",
|
|
53
|
+
algo: CurseforgeHash.Md5,
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
}),
|
|
57
|
+
),
|
|
58
|
+
).toBe("aabb");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("returns null when the file carries no sha1", () => {
|
|
62
|
+
expect(curseforgeSha1(file())).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("curseforgeDownloadUrl", () => {
|
|
67
|
+
test("re-encodes the path of a curseforge cdn url", () => {
|
|
68
|
+
expect(
|
|
69
|
+
curseforgeDownloadUrl(
|
|
70
|
+
file({
|
|
71
|
+
downloadUrl: "https://edge.forgecdn.net/files/4711/12/my mod.jar",
|
|
72
|
+
}),
|
|
73
|
+
),
|
|
74
|
+
).toBe("https://edge.forgecdn.net/files/4711/12/my%20mod.jar");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("refuses a url that is not on the curseforge cdn", () => {
|
|
78
|
+
expect(
|
|
79
|
+
curseforgeDownloadUrl(
|
|
80
|
+
file({
|
|
81
|
+
downloadUrl: "https://evil.test/files/mod.jar",
|
|
82
|
+
}),
|
|
83
|
+
),
|
|
84
|
+
).toBeNull();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("keeps a name curseforge already escaped escaped once", () => {
|
|
88
|
+
expect(
|
|
89
|
+
curseforgeDownloadUrl(
|
|
90
|
+
file({
|
|
91
|
+
downloadUrl: "https://edge.forgecdn.net/files/1234/567/Some%20Mod%20%5B1.20%5D.jar",
|
|
92
|
+
}),
|
|
93
|
+
),
|
|
94
|
+
).toBe("https://edge.forgecdn.net/files/1234/567/Some%20Mod%20%5B1.20%5D.jar");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("refuses a path carrying a broken escape", () => {
|
|
98
|
+
expect(
|
|
99
|
+
curseforgeDownloadUrl(
|
|
100
|
+
file({
|
|
101
|
+
downloadUrl: "https://edge.forgecdn.net/files/1234/567/100% complete.jar",
|
|
102
|
+
}),
|
|
103
|
+
),
|
|
104
|
+
).toBeNull();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("refuses plain http and a missing url", () => {
|
|
108
|
+
expect(
|
|
109
|
+
curseforgeDownloadUrl(
|
|
110
|
+
file({
|
|
111
|
+
downloadUrl: "http://edge.forgecdn.net/files/1/2/mod.jar",
|
|
112
|
+
}),
|
|
113
|
+
),
|
|
114
|
+
).toBeNull();
|
|
115
|
+
expect(
|
|
116
|
+
curseforgeDownloadUrl(
|
|
117
|
+
file({
|
|
118
|
+
downloadUrl: null,
|
|
119
|
+
}),
|
|
120
|
+
),
|
|
121
|
+
).toBeNull();
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("curseforgeFallbackUrl", () => {
|
|
126
|
+
test("splits a modern file id into four and the rest", () => {
|
|
127
|
+
expect(curseforgeFallbackUrl(4_711_123, "mod.jar")).toBe(`${CURSEFORGE_DOWNLOAD}/files/4711/123/mod.jar`);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("divides a legacy file id by a thousand", () => {
|
|
131
|
+
expect(curseforgeFallbackUrl(123_456, "mod.jar")).toBe(`${CURSEFORGE_DOWNLOAD}/files/123/123456/mod.jar`);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("percent encodes a file name carrying spaces and brackets", () => {
|
|
135
|
+
expect(curseforgeFallbackUrl(4_711_123, "Butchersdelight beta 1.20.1 2.1.0.jar")).toBe(
|
|
136
|
+
`${CURSEFORGE_DOWNLOAD}/files/4711/123/Butchersdelight%20beta%201.20.1%202.1.0.jar`,
|
|
137
|
+
);
|
|
138
|
+
expect(curseforgeFallbackUrl(4_711_123, "Some Mod [1.20].jar")).toBe(
|
|
139
|
+
`${CURSEFORGE_DOWNLOAD}/files/4711/123/Some%20Mod%20%5B1.20%5D.jar`,
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("isCurseforgeServerFile", () => {
|
|
145
|
+
test("accepts a file tagged server", () => {
|
|
146
|
+
expect(
|
|
147
|
+
isCurseforgeServerFile(
|
|
148
|
+
file({
|
|
149
|
+
gameVersions: [
|
|
150
|
+
"Client",
|
|
151
|
+
"Server",
|
|
152
|
+
],
|
|
153
|
+
}),
|
|
154
|
+
),
|
|
155
|
+
).toBe(true);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("refuses a file tagged client only", () => {
|
|
159
|
+
expect(
|
|
160
|
+
isCurseforgeServerFile(
|
|
161
|
+
file({
|
|
162
|
+
gameVersions: [
|
|
163
|
+
"Client",
|
|
164
|
+
],
|
|
165
|
+
}),
|
|
166
|
+
),
|
|
167
|
+
).toBe(false);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("accepts a file with no side tag", () => {
|
|
171
|
+
expect(
|
|
172
|
+
isCurseforgeServerFile(
|
|
173
|
+
file({
|
|
174
|
+
gameVersions: [
|
|
175
|
+
"1.21",
|
|
176
|
+
],
|
|
177
|
+
}),
|
|
178
|
+
),
|
|
179
|
+
).toBe(true);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("reads the side tags whatever their casing", () => {
|
|
183
|
+
expect(
|
|
184
|
+
isCurseforgeServerFile(
|
|
185
|
+
file({
|
|
186
|
+
gameVersions: [
|
|
187
|
+
"client",
|
|
188
|
+
"server",
|
|
189
|
+
],
|
|
190
|
+
}),
|
|
191
|
+
),
|
|
192
|
+
).toBe(true);
|
|
193
|
+
expect(
|
|
194
|
+
isCurseforgeServerFile(
|
|
195
|
+
file({
|
|
196
|
+
gameVersions: [
|
|
197
|
+
"client",
|
|
198
|
+
],
|
|
199
|
+
}),
|
|
200
|
+
),
|
|
201
|
+
).toBe(false);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
describe("selectCurseforgeFile", () => {
|
|
206
|
+
test("returns null when nothing is available", () => {
|
|
207
|
+
expect(
|
|
208
|
+
selectCurseforgeFile([
|
|
209
|
+
file({
|
|
210
|
+
isAvailable: false,
|
|
211
|
+
}),
|
|
212
|
+
]),
|
|
213
|
+
).toBeNull();
|
|
214
|
+
expect(selectCurseforgeFile([])).toBeNull();
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("prefers the newest file", () => {
|
|
218
|
+
expect(
|
|
219
|
+
selectCurseforgeFile([
|
|
220
|
+
file({
|
|
221
|
+
id: 1,
|
|
222
|
+
}),
|
|
223
|
+
file({
|
|
224
|
+
id: 9,
|
|
225
|
+
}),
|
|
226
|
+
file({
|
|
227
|
+
id: 5,
|
|
228
|
+
}),
|
|
229
|
+
])?.id,
|
|
230
|
+
).toBe(9);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("prefers a file matching the game version", () => {
|
|
234
|
+
expect(
|
|
235
|
+
selectCurseforgeFile(
|
|
236
|
+
[
|
|
237
|
+
file({
|
|
238
|
+
id: 9,
|
|
239
|
+
gameVersions: [
|
|
240
|
+
"1.20",
|
|
241
|
+
],
|
|
242
|
+
}),
|
|
243
|
+
file({
|
|
244
|
+
id: 2,
|
|
245
|
+
gameVersions: [
|
|
246
|
+
"1.21",
|
|
247
|
+
],
|
|
248
|
+
}),
|
|
249
|
+
],
|
|
250
|
+
{
|
|
251
|
+
gameVersion: "1.21",
|
|
252
|
+
},
|
|
253
|
+
)?.id,
|
|
254
|
+
).toBe(2);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("falls back to the newest when the game version is nowhere", () => {
|
|
258
|
+
expect(
|
|
259
|
+
selectCurseforgeFile(
|
|
260
|
+
[
|
|
261
|
+
file({
|
|
262
|
+
id: 9,
|
|
263
|
+
gameVersions: [
|
|
264
|
+
"1.20",
|
|
265
|
+
],
|
|
266
|
+
}),
|
|
267
|
+
],
|
|
268
|
+
{
|
|
269
|
+
gameVersion: "1.99",
|
|
270
|
+
},
|
|
271
|
+
)?.id,
|
|
272
|
+
).toBe(9);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test("keeps stable releases when asked and falls back when there are none", () => {
|
|
276
|
+
const files = [
|
|
277
|
+
file({
|
|
278
|
+
id: 9,
|
|
279
|
+
releaseType: CurseforgeReleaseType.Alpha,
|
|
280
|
+
}),
|
|
281
|
+
file({
|
|
282
|
+
id: 3,
|
|
283
|
+
releaseType: CurseforgeReleaseType.Release,
|
|
284
|
+
}),
|
|
285
|
+
];
|
|
286
|
+
|
|
287
|
+
expect(
|
|
288
|
+
selectCurseforgeFile(files, {
|
|
289
|
+
stableOnly: true,
|
|
290
|
+
})?.id,
|
|
291
|
+
).toBe(3);
|
|
292
|
+
expect(
|
|
293
|
+
selectCurseforgeFile(
|
|
294
|
+
[
|
|
295
|
+
files[0] ?? file(),
|
|
296
|
+
],
|
|
297
|
+
{
|
|
298
|
+
stableOnly: true,
|
|
299
|
+
},
|
|
300
|
+
)?.id,
|
|
301
|
+
).toBe(9);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("drops client-only files when asked for a server file", () => {
|
|
305
|
+
expect(
|
|
306
|
+
selectCurseforgeFile(
|
|
307
|
+
[
|
|
308
|
+
file({
|
|
309
|
+
id: 9,
|
|
310
|
+
gameVersions: [
|
|
311
|
+
"Client",
|
|
312
|
+
],
|
|
313
|
+
}),
|
|
314
|
+
file({
|
|
315
|
+
id: 3,
|
|
316
|
+
gameVersions: [
|
|
317
|
+
"Server",
|
|
318
|
+
],
|
|
319
|
+
}),
|
|
320
|
+
],
|
|
321
|
+
{
|
|
322
|
+
serverOnly: true,
|
|
323
|
+
},
|
|
324
|
+
)?.id,
|
|
325
|
+
).toBe(3);
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
interface CatalogCall {
|
|
330
|
+
url: string;
|
|
331
|
+
cacheSeconds: number | undefined;
|
|
332
|
+
headers: Record<string, string> | undefined;
|
|
333
|
+
httpMethod: BridgeHttpMethod | undefined;
|
|
334
|
+
body: string | undefined;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const catalogContext = (
|
|
338
|
+
overrides: { key?: string | null; result?: unknown; fail?: Error; options?: Partial<CurseforgeCatalogOptions> } = {},
|
|
339
|
+
) => {
|
|
340
|
+
const calls: CatalogCall[] = [];
|
|
341
|
+
const key = overrides.key === undefined ? "test-key" : overrides.key;
|
|
342
|
+
|
|
343
|
+
const context = {
|
|
344
|
+
secret(name: string) {
|
|
345
|
+
return name === CURSEFORGE_SECRET ? key : null;
|
|
346
|
+
},
|
|
347
|
+
net: {
|
|
348
|
+
async json(url: string, options: BridgeRequestOptions = {}) {
|
|
349
|
+
calls.push({
|
|
350
|
+
url,
|
|
351
|
+
cacheSeconds: options.cacheSeconds,
|
|
352
|
+
headers: options.headers,
|
|
353
|
+
httpMethod: options.httpMethod,
|
|
354
|
+
body: options.body,
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
if (overrides.fail) {
|
|
358
|
+
throw overrides.fail;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return (
|
|
362
|
+
overrides.result ?? {
|
|
363
|
+
data: [],
|
|
364
|
+
}
|
|
365
|
+
);
|
|
366
|
+
},
|
|
367
|
+
},
|
|
368
|
+
} as unknown as BridgeContext;
|
|
369
|
+
|
|
370
|
+
return {
|
|
371
|
+
calls,
|
|
372
|
+
catalog: createCurseforgeCatalog(context, {
|
|
373
|
+
gameId: 432,
|
|
374
|
+
...overrides.options,
|
|
375
|
+
}),
|
|
376
|
+
};
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
const searchParams = (calls: CatalogCall[], index = 0) => {
|
|
380
|
+
return new URL(calls[index]?.url ?? "https://curseforge.test").searchParams;
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
const batchIds = (calls: CatalogCall[], index: number) => {
|
|
384
|
+
const parsed: unknown = JSON.parse(calls[index]?.body ?? "null");
|
|
385
|
+
|
|
386
|
+
if (typeof parsed !== "object" || parsed === null || !("fileIds" in parsed)) {
|
|
387
|
+
return [];
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const ids = (parsed as Record<string, unknown>).fileIds;
|
|
391
|
+
|
|
392
|
+
return Array.isArray(ids) ? (ids as number[]) : [];
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
describe("createCurseforgeCatalog", () => {
|
|
396
|
+
test("sends the key as a header and the search cache window", async () => {
|
|
397
|
+
const { catalog, calls } = catalogContext();
|
|
398
|
+
|
|
399
|
+
await catalog.search();
|
|
400
|
+
|
|
401
|
+
expect(calls[0]?.headers).toEqual({
|
|
402
|
+
"x-api-key": "test-key",
|
|
403
|
+
});
|
|
404
|
+
expect(calls[0]?.cacheSeconds).toBe(60);
|
|
405
|
+
expect(searchParams(calls).get("gameId")).toBe("432");
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
test("leaves the sort order out when no sort is asked for", async () => {
|
|
409
|
+
const { catalog, calls } = catalogContext();
|
|
410
|
+
|
|
411
|
+
await catalog.search();
|
|
412
|
+
|
|
413
|
+
expect(searchParams(calls).has("sortOrder")).toBe(false);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
test("sorts a popularity search descending", async () => {
|
|
417
|
+
const { catalog, calls } = catalogContext();
|
|
418
|
+
|
|
419
|
+
await catalog.search({
|
|
420
|
+
sort: CurseforgeSort.Popularity,
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
expect(searchParams(calls).get("sortField")).toBe(CurseforgeSort.Popularity);
|
|
424
|
+
expect(searchParams(calls).get("sortOrder")).toBe(CurseforgeSortOrder.Desc);
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
test("sorts a name search ascending", async () => {
|
|
428
|
+
const { catalog, calls } = catalogContext();
|
|
429
|
+
|
|
430
|
+
await catalog.search({
|
|
431
|
+
sort: CurseforgeSort.Name,
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
expect(searchParams(calls).get("sortOrder")).toBe(CurseforgeSortOrder.Asc);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
test("sorts an author search ascending", async () => {
|
|
438
|
+
const { catalog, calls } = catalogContext();
|
|
439
|
+
|
|
440
|
+
await catalog.search({
|
|
441
|
+
sort: CurseforgeSort.Author,
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
expect(searchParams(calls).get("sortOrder")).toBe(CurseforgeSortOrder.Asc);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
test("keeps an explicit sort order", async () => {
|
|
448
|
+
const { catalog, calls } = catalogContext();
|
|
449
|
+
|
|
450
|
+
await catalog.search({
|
|
451
|
+
sort: CurseforgeSort.Name,
|
|
452
|
+
sortOrder: CurseforgeSortOrder.Desc,
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
expect(searchParams(calls).get("sortOrder")).toBe(CurseforgeSortOrder.Desc);
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
test("drops empty and absent filters but keeps a zero index", async () => {
|
|
459
|
+
const { catalog, calls } = catalogContext();
|
|
460
|
+
|
|
461
|
+
await catalog.search({
|
|
462
|
+
query: "",
|
|
463
|
+
gameVersion: "",
|
|
464
|
+
index: 0,
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
const params = searchParams(calls);
|
|
468
|
+
|
|
469
|
+
expect(params.has("searchFilter")).toBe(false);
|
|
470
|
+
expect(params.has("gameVersion")).toBe(false);
|
|
471
|
+
expect(params.has("classId")).toBe(false);
|
|
472
|
+
expect(params.get("index")).toBe("0");
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
test("asks for the files of a mod without a query string", async () => {
|
|
476
|
+
const { catalog, calls } = catalogContext();
|
|
477
|
+
|
|
478
|
+
await catalog.modFiles(4711);
|
|
479
|
+
|
|
480
|
+
expect(calls[0]?.url).toBe(`${CURSEFORGE_API}/mods/4711/files`);
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
test("passes every mod file filter through", async () => {
|
|
484
|
+
const { catalog, calls } = catalogContext();
|
|
485
|
+
|
|
486
|
+
await catalog.modFiles(4711, {
|
|
487
|
+
gameVersion: "1.21",
|
|
488
|
+
modLoaderType: 4,
|
|
489
|
+
index: 0,
|
|
490
|
+
pageSize: 50,
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
const params = searchParams(calls);
|
|
494
|
+
|
|
495
|
+
expect(params.get("gameVersion")).toBe("1.21");
|
|
496
|
+
expect(params.get("modLoaderType")).toBe("4");
|
|
497
|
+
expect(params.get("index")).toBe("0");
|
|
498
|
+
expect(params.get("pageSize")).toBe("50");
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
test("caches categories for a day rather than for the project window", async () => {
|
|
502
|
+
const { catalog, calls } = catalogContext({
|
|
503
|
+
options: {
|
|
504
|
+
projectCacheSeconds: 5,
|
|
505
|
+
},
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
await catalog.categories(6);
|
|
509
|
+
|
|
510
|
+
expect(calls[0]?.cacheSeconds).toBe(86_400);
|
|
511
|
+
expect(searchParams(calls).get("classId")).toBe("6");
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
test("dedupes, sorts and batches file ids into posted bodies", async () => {
|
|
515
|
+
const { catalog, calls } = catalogContext();
|
|
516
|
+
|
|
517
|
+
await catalog.files([
|
|
518
|
+
...Array.from({
|
|
519
|
+
length: 150,
|
|
520
|
+
}).map((_, index) => 150 - index),
|
|
521
|
+
150,
|
|
522
|
+
]);
|
|
523
|
+
|
|
524
|
+
expect(calls).toHaveLength(2);
|
|
525
|
+
expect(calls[0]?.httpMethod).toBe(BridgeHttpMethod.Post);
|
|
526
|
+
expect(calls[0]?.url).toBe(`${CURSEFORGE_API}/mods/files`);
|
|
527
|
+
|
|
528
|
+
const first = batchIds(calls, 0);
|
|
529
|
+
const second = batchIds(calls, 1);
|
|
530
|
+
|
|
531
|
+
expect(first).toHaveLength(100);
|
|
532
|
+
expect(first.at(0)).toBe(1);
|
|
533
|
+
expect(first.at(-1)).toBe(100);
|
|
534
|
+
expect(second).toHaveLength(50);
|
|
535
|
+
expect(second.at(0)).toBe(101);
|
|
536
|
+
expect(second.at(-1)).toBe(150);
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
test("refuses to work without a key", async () => {
|
|
540
|
+
const { catalog, calls } = catalogContext({
|
|
541
|
+
key: null,
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
expect(catalog.ready()).toBe(false);
|
|
545
|
+
await expect(catalog.search()).rejects.toBeInstanceOf(BridgeSecretError);
|
|
546
|
+
expect(calls).toHaveLength(0);
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
test("turns a rejected key into a secret error", async () => {
|
|
550
|
+
for (const status of [
|
|
551
|
+
401,
|
|
552
|
+
403,
|
|
553
|
+
]) {
|
|
554
|
+
const { catalog } = catalogContext({
|
|
555
|
+
fail: new BridgeNetError(status, "denied"),
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
await expect(catalog.search()).rejects.toBeInstanceOf(BridgeSecretError);
|
|
559
|
+
}
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
test("turns throttling into a rate limited failure", async () => {
|
|
563
|
+
for (const status of [
|
|
564
|
+
429,
|
|
565
|
+
503,
|
|
566
|
+
]) {
|
|
567
|
+
const { catalog } = catalogContext({
|
|
568
|
+
fail: new BridgeNetError(status, "slow down"),
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
const failure = await catalog.search().then(
|
|
572
|
+
() => null,
|
|
573
|
+
(error: unknown) => error,
|
|
574
|
+
);
|
|
575
|
+
|
|
576
|
+
expect(failure).toBeInstanceOf(BridgeFailureError);
|
|
577
|
+
expect(failure instanceof BridgeFailureError ? failure.code : null).toBe(BridgeFailureCode.CatalogRateLimited);
|
|
578
|
+
}
|
|
579
|
+
});
|
|
580
|
+
});
|