@serverkgg/bridge 0.0.14 → 0.0.16
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 +1 -1
- package/src/cli/validate.test.ts +194 -2
- package/src/cli/validate.ts +83 -1
- package/src/guides/variables.ts +1 -0
- package/src/panel/bridgePanel.ts +11 -0
- package/src/panel/bridgePlace.ts +4 -0
- package/src/panel/index.ts +1 -0
- package/src/protocol/bridgeCrash.ts +83 -0
- package/src/protocol/bridgeFrame.ts +1 -0
- package/src/protocol/index.ts +1 -0
- package/src/rcon/rconAccess.test.ts +20 -2
- package/src/rcon/rconAccess.ts +9 -1
- package/src/runtime/bridgeDriver.test.ts +145 -0
- package/src/runtime/bridgeDriver.ts +44 -2
- package/src/runtime/bridgeEvents.test.ts +411 -9
- package/src/runtime/bridgeEvents.ts +53 -3
- package/src/runtime/bridgeRegistry.test.ts +96 -0
- package/src/runtime/bridgeRegistry.ts +33 -1
- package/src/runtime/bridgeServer.test.ts +1 -0
- package/src/runtime/bridgeSupervisor.test.ts +289 -0
- package/src/runtime/bridgeSupervisor.ts +166 -9
- package/src/runtime/serve.ts +10 -0
- package/src/steam/avatars.test.ts +306 -0
- package/src/steam/avatars.ts +107 -0
- package/src/steam/index.ts +1 -0
- package/src/wire/panel.test.ts +79 -0
- package/src/wire/panel.ts +11 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { BridgeCommandOptions, BridgeReply } from "../context";
|
|
2
2
|
import { BridgeRuntime, BridgeStream } from "../core";
|
|
3
|
+
import { BRIDGE_CRASH_LINE_LIMIT, BridgeCrashPhase, type BridgeCrashRecord } from "../protocol";
|
|
3
4
|
import { stripAnsi } from "../utils";
|
|
4
5
|
import type { BridgeLeveledLogger } from "./bridgeLog";
|
|
5
6
|
import { errorText } from "./bridgeLog";
|
|
@@ -14,9 +15,11 @@ const KILL_GRACE_MS = 5000;
|
|
|
14
15
|
|
|
15
16
|
const RESTART_DELAY_MS = 2000;
|
|
16
17
|
|
|
17
|
-
const RESTART_WINDOW_MS =
|
|
18
|
+
const RESTART_WINDOW_MS = 600_000;
|
|
18
19
|
|
|
19
|
-
const RESTART_LIMIT =
|
|
20
|
+
const RESTART_LIMIT = 3;
|
|
21
|
+
|
|
22
|
+
const STABLE_UPTIME_MS = 180_000;
|
|
20
23
|
|
|
21
24
|
const GAME_OOM_SCORE_ADJ = 500;
|
|
22
25
|
|
|
@@ -35,7 +38,11 @@ export interface SupervisorOptions {
|
|
|
35
38
|
ready?: RegExp;
|
|
36
39
|
resolveCommand: () => Promise<string[]>;
|
|
37
40
|
gracefulStop?: () => Promise<void>;
|
|
41
|
+
stableAfterMs?: number;
|
|
38
42
|
onReady?: () => void;
|
|
43
|
+
onStable?: () => void;
|
|
44
|
+
onStopped?: () => void;
|
|
45
|
+
onExit?: (record: BridgeCrashRecord) => void;
|
|
39
46
|
onLog: BridgeLeveledLogger;
|
|
40
47
|
}
|
|
41
48
|
|
|
@@ -119,6 +126,15 @@ const splitLines = (buffer: string, chunk: string) => {
|
|
|
119
126
|
};
|
|
120
127
|
};
|
|
121
128
|
|
|
129
|
+
const PUMP_DRAIN_MS = 500;
|
|
130
|
+
|
|
131
|
+
const drainPumps = async (pumps: readonly Promise<unknown>[]) => {
|
|
132
|
+
await Promise.race([
|
|
133
|
+
Promise.all(pumps),
|
|
134
|
+
Bun.sleep(PUMP_DRAIN_MS),
|
|
135
|
+
]);
|
|
136
|
+
};
|
|
137
|
+
|
|
122
138
|
export const createSupervisor = (options: SupervisorOptions) => {
|
|
123
139
|
const tail: SupervisorLine[] = [];
|
|
124
140
|
const watchers = new Set<LineWatcher>();
|
|
@@ -132,8 +148,21 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
132
148
|
let exitCode: number | null = null;
|
|
133
149
|
let outOfMemory = false;
|
|
134
150
|
let restarts = 0;
|
|
151
|
+
let launchedAt = performance.now();
|
|
152
|
+
let stable = false;
|
|
153
|
+
let stableTimer: ReturnType<typeof setTimeout> | null = null;
|
|
135
154
|
let queue: Promise<unknown> = Promise.resolve();
|
|
136
155
|
|
|
156
|
+
const markStable = () => {
|
|
157
|
+
if (stable) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
stable = true;
|
|
162
|
+
|
|
163
|
+
options.onStable?.();
|
|
164
|
+
};
|
|
165
|
+
|
|
137
166
|
const emit = (entry: SupervisorLine) => {
|
|
138
167
|
if (entry.stream === BridgeStream.Stderr) {
|
|
139
168
|
process.stderr.write(`${entry.line}\n`);
|
|
@@ -223,6 +252,18 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
223
252
|
return restartStamps.length >= RESTART_LIMIT;
|
|
224
253
|
};
|
|
225
254
|
|
|
255
|
+
const lastLine = () => {
|
|
256
|
+
for (let index = tail.length - 1; index >= 0; index -= 1) {
|
|
257
|
+
const line = stripAnsi(tail.at(index)?.line ?? "").trim();
|
|
258
|
+
|
|
259
|
+
if (line.length > 0) {
|
|
260
|
+
return line.slice(0, BRIDGE_CRASH_LINE_LIMIT);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return null;
|
|
265
|
+
};
|
|
266
|
+
|
|
226
267
|
const launch = async () => {
|
|
227
268
|
const command = await options.resolveCommand();
|
|
228
269
|
const [executable, ...rest] = command;
|
|
@@ -237,6 +278,34 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
237
278
|
];
|
|
238
279
|
|
|
239
280
|
const { argv: spawnArgv, credentials } = await unprivilegedArgv(argv, options.uid, options.gid);
|
|
281
|
+
|
|
282
|
+
launchedAt = performance.now();
|
|
283
|
+
stable = false;
|
|
284
|
+
|
|
285
|
+
if (stableTimer) {
|
|
286
|
+
clearTimeout(stableTimer);
|
|
287
|
+
|
|
288
|
+
stableTimer = null;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
let accepted = false;
|
|
292
|
+
let reported = false;
|
|
293
|
+
let pumps: Promise<unknown>[] = [];
|
|
294
|
+
|
|
295
|
+
const finish = (code: number) => {
|
|
296
|
+
if (!accepted || reported) {
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
reported = true;
|
|
301
|
+
|
|
302
|
+
void handleExit(code, pumps).catch((error: unknown) => {
|
|
303
|
+
options.onLog.error("the game exit was not reported", {
|
|
304
|
+
error: errorText(error),
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
};
|
|
308
|
+
|
|
240
309
|
const spawned = Bun.spawn(spawnArgv, {
|
|
241
310
|
...credentials,
|
|
242
311
|
cwd: options.cwd,
|
|
@@ -245,7 +314,7 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
245
314
|
stdin: "pipe",
|
|
246
315
|
stdout: "pipe",
|
|
247
316
|
onExit(_subprocess, code, signal) {
|
|
248
|
-
|
|
317
|
+
finish(exitCodeOf(code, signal));
|
|
249
318
|
},
|
|
250
319
|
});
|
|
251
320
|
|
|
@@ -281,8 +350,22 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
281
350
|
childGroup = group;
|
|
282
351
|
exitCode = null;
|
|
283
352
|
|
|
284
|
-
|
|
285
|
-
|
|
353
|
+
const launchStamp = launchedAt;
|
|
354
|
+
|
|
355
|
+
stableTimer = setTimeout(() => {
|
|
356
|
+
if (launchStamp !== launchedAt || child === null) {
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
markStable();
|
|
361
|
+
}, options.stableAfterMs ?? STABLE_UPTIME_MS);
|
|
362
|
+
|
|
363
|
+
pumps = [
|
|
364
|
+
pump(spawned.stdout, BridgeStream.Stdout).catch(() => undefined),
|
|
365
|
+
pump(spawned.stderr, BridgeStream.Stderr).catch(() => undefined),
|
|
366
|
+
];
|
|
367
|
+
|
|
368
|
+
accepted = true;
|
|
286
369
|
|
|
287
370
|
options.onLog("game started", {
|
|
288
371
|
pid: spawned.pid,
|
|
@@ -290,6 +373,12 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
290
373
|
uid: options.uid,
|
|
291
374
|
});
|
|
292
375
|
|
|
376
|
+
if (spawned.exitCode !== null || spawned.signalCode !== null) {
|
|
377
|
+
finish(exitCodeOf(spawned.exitCode, spawned.signalCode));
|
|
378
|
+
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
|
|
293
382
|
if (!options.ready) {
|
|
294
383
|
runtime = BridgeRuntime.Running;
|
|
295
384
|
|
|
@@ -299,12 +388,30 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
299
388
|
runtime = BridgeRuntime.Starting;
|
|
300
389
|
|
|
301
390
|
void watch(options.ready, options.stopTimeoutSeconds * 1000 * 10).then((match) => {
|
|
302
|
-
if (
|
|
391
|
+
if (launchStamp !== launchedAt || child === null) {
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (match) {
|
|
396
|
+
if (runtime === BridgeRuntime.Starting) {
|
|
397
|
+
runtime = BridgeRuntime.Running;
|
|
398
|
+
|
|
399
|
+
options.onLog("game ready");
|
|
400
|
+
options.onReady?.();
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
markStable();
|
|
404
|
+
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (runtime === BridgeRuntime.Starting) {
|
|
303
409
|
runtime = BridgeRuntime.Running;
|
|
304
410
|
|
|
305
|
-
options.onLog("game ready");
|
|
306
|
-
options.onReady?.();
|
|
411
|
+
options.onLog.warn("game ready line not seen, treating it as running");
|
|
307
412
|
}
|
|
413
|
+
|
|
414
|
+
markStable();
|
|
308
415
|
});
|
|
309
416
|
};
|
|
310
417
|
|
|
@@ -326,12 +433,18 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
326
433
|
}
|
|
327
434
|
};
|
|
328
435
|
|
|
329
|
-
function handleExit(code: number) {
|
|
436
|
+
async function handleExit(code: number, pumps: readonly Promise<unknown>[]) {
|
|
330
437
|
const finished = child;
|
|
331
438
|
|
|
332
439
|
child = null;
|
|
333
440
|
exitCode = code;
|
|
334
441
|
|
|
442
|
+
if (stableTimer) {
|
|
443
|
+
clearTimeout(stableTimer);
|
|
444
|
+
|
|
445
|
+
stableTimer = null;
|
|
446
|
+
}
|
|
447
|
+
|
|
335
448
|
if (finished) {
|
|
336
449
|
sweep(finished.pid, childGroup);
|
|
337
450
|
}
|
|
@@ -350,6 +463,9 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
350
463
|
return;
|
|
351
464
|
}
|
|
352
465
|
|
|
466
|
+
const uptimeMs = performance.now() - launchedAt;
|
|
467
|
+
const phase = stable ? BridgeCrashPhase.Running : BridgeCrashPhase.Booting;
|
|
468
|
+
|
|
353
469
|
outOfMemory = code === SIGKILL_EXIT_CODE;
|
|
354
470
|
|
|
355
471
|
if (outOfMemory) {
|
|
@@ -358,6 +474,38 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
358
474
|
});
|
|
359
475
|
}
|
|
360
476
|
|
|
477
|
+
const report = async (restarting: boolean, looping: boolean) => {
|
|
478
|
+
const settled = {
|
|
479
|
+
exitCode: code,
|
|
480
|
+
outOfMemory,
|
|
481
|
+
phase,
|
|
482
|
+
uptimeSeconds: Math.floor(uptimeMs / 1000),
|
|
483
|
+
restarting,
|
|
484
|
+
looping,
|
|
485
|
+
restarts,
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
await drainPumps(pumps);
|
|
489
|
+
|
|
490
|
+
options.onExit?.({
|
|
491
|
+
...settled,
|
|
492
|
+
lastLine: lastLine(),
|
|
493
|
+
});
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
if (phase === BridgeCrashPhase.Booting) {
|
|
497
|
+
runtime = BridgeRuntime.Crashed;
|
|
498
|
+
|
|
499
|
+
options.onLog.error("game exited while booting, not restarting", {
|
|
500
|
+
code,
|
|
501
|
+
phase,
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
await report(false, false);
|
|
505
|
+
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
|
|
361
509
|
restarts += 1;
|
|
362
510
|
restartStamps.push(performance.now());
|
|
363
511
|
|
|
@@ -370,6 +518,8 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
370
518
|
restarts,
|
|
371
519
|
});
|
|
372
520
|
|
|
521
|
+
await report(false, true);
|
|
522
|
+
|
|
373
523
|
return;
|
|
374
524
|
}
|
|
375
525
|
|
|
@@ -380,6 +530,8 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
380
530
|
restarts,
|
|
381
531
|
});
|
|
382
532
|
|
|
533
|
+
await report(true, false);
|
|
534
|
+
|
|
383
535
|
void restart();
|
|
384
536
|
}
|
|
385
537
|
|
|
@@ -429,6 +581,7 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
429
581
|
shouldRun = true;
|
|
430
582
|
breaker = false;
|
|
431
583
|
outOfMemory = false;
|
|
584
|
+
restarts = 0;
|
|
432
585
|
restartStamps.length = 0;
|
|
433
586
|
|
|
434
587
|
if (child) {
|
|
@@ -444,6 +597,8 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
444
597
|
if (!child) {
|
|
445
598
|
runtime = BridgeRuntime.Stopped;
|
|
446
599
|
|
|
600
|
+
options.onStopped?.();
|
|
601
|
+
|
|
447
602
|
return;
|
|
448
603
|
}
|
|
449
604
|
|
|
@@ -488,6 +643,8 @@ export const createSupervisor = (options: SupervisorOptions) => {
|
|
|
488
643
|
sweep(active.pid, group);
|
|
489
644
|
|
|
490
645
|
runtime = BridgeRuntime.Stopped;
|
|
646
|
+
|
|
647
|
+
options.onStopped?.();
|
|
491
648
|
},
|
|
492
649
|
|
|
493
650
|
async command(input: string, commandOptions: BridgeCommandOptions = {}): Promise<BridgeReply> {
|
package/src/runtime/serve.ts
CHANGED
|
@@ -96,6 +96,15 @@ export const serve = async (driver: BridgeDriver) => {
|
|
|
96
96
|
});
|
|
97
97
|
});
|
|
98
98
|
},
|
|
99
|
+
onStable() {
|
|
100
|
+
ring.discard();
|
|
101
|
+
},
|
|
102
|
+
onStopped() {
|
|
103
|
+
ring.discard();
|
|
104
|
+
},
|
|
105
|
+
onExit(record) {
|
|
106
|
+
ring.crash(record);
|
|
107
|
+
},
|
|
99
108
|
});
|
|
100
109
|
|
|
101
110
|
const ring = createEventRing(driver.events, supervisor);
|
|
@@ -130,6 +139,7 @@ export const serve = async (driver: BridgeDriver) => {
|
|
|
130
139
|
exitCode: supervisor.exitCode,
|
|
131
140
|
outOfMemory: supervisor.outOfMemory,
|
|
132
141
|
restarts: supervisor.restarts,
|
|
142
|
+
crashRecords: true,
|
|
133
143
|
installError,
|
|
134
144
|
installErrorText,
|
|
135
145
|
installHold,
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { afterEach, describe, expect, setSystemTime, test } from "bun:test";
|
|
2
|
+
import type { BridgeContext } from "../context";
|
|
3
|
+
import type { BridgeValues } from "../core";
|
|
4
|
+
import { createSteamAvatars, STEAM_AVATAR_CACHE_TTL_MS, steamAvatarHashOf } from "./avatars";
|
|
5
|
+
|
|
6
|
+
const ALICE_HASH = "a".repeat(40);
|
|
7
|
+
|
|
8
|
+
const BOB_HASH = "b".repeat(40);
|
|
9
|
+
|
|
10
|
+
const ALICE = "76561198000000001";
|
|
11
|
+
|
|
12
|
+
const BOB = "76561198000000002";
|
|
13
|
+
|
|
14
|
+
const identity = (id: string) => (id.startsWith("7656") ? id : null);
|
|
15
|
+
|
|
16
|
+
interface Warning {
|
|
17
|
+
message: string;
|
|
18
|
+
fields: BridgeValues | undefined;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface Summary {
|
|
22
|
+
steamid: string;
|
|
23
|
+
avatarfull: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface FakeSteam {
|
|
27
|
+
context: BridgeContext;
|
|
28
|
+
calls: string[][];
|
|
29
|
+
warnings: Warning[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const summaryOf = (steamId: string, hash: string): Summary => {
|
|
33
|
+
return {
|
|
34
|
+
steamid: steamId,
|
|
35
|
+
avatarfull: `https://avatars.steamstatic.com/${hash}_full.jpg`,
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const fakeSteam = (options: { key?: string | null; answer?: (steamIds: string[]) => Summary[] }): FakeSteam => {
|
|
40
|
+
const calls: string[][] = [];
|
|
41
|
+
const warnings: Warning[] = [];
|
|
42
|
+
|
|
43
|
+
const context = {
|
|
44
|
+
secret(key: string) {
|
|
45
|
+
return key === "STEAM_WEB_API_KEY" ? (options.key === undefined ? "secret-key" : options.key) : null;
|
|
46
|
+
},
|
|
47
|
+
net: {
|
|
48
|
+
async json(url: string) {
|
|
49
|
+
const steamIds = (new URL(url).searchParams.get("steamids") ?? "").split(",").filter((id) => id.length > 0);
|
|
50
|
+
|
|
51
|
+
calls.push(steamIds);
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
response: {
|
|
55
|
+
players: options.answer?.(steamIds) ?? [],
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
log: {
|
|
61
|
+
warn(message: string, fields?: BridgeValues) {
|
|
62
|
+
warnings.push({
|
|
63
|
+
message,
|
|
64
|
+
fields,
|
|
65
|
+
});
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
} as unknown as BridgeContext;
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
context,
|
|
72
|
+
calls,
|
|
73
|
+
warnings,
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
afterEach(() => {
|
|
78
|
+
setSystemTime();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe("reading the avatar hash out of a steam profile picture", () => {
|
|
82
|
+
test("takes the hash out of the full-size url steam answers with", () => {
|
|
83
|
+
expect(steamAvatarHashOf(`https://avatars.steamstatic.com/${ALICE_HASH}_full.jpg`)).toBe(ALICE_HASH);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("takes the hash out of a url that carries no size suffix", () => {
|
|
87
|
+
expect(steamAvatarHashOf(`https://avatars.steamstatic.com/${BOB_HASH}.jpg`)).toBe(BOB_HASH);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("reports no hash for a url that is not a steam avatar", () => {
|
|
91
|
+
expect(steamAvatarHashOf("https://example.com/avatar.png")).toBeNull();
|
|
92
|
+
expect(steamAvatarHashOf("")).toBeNull();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("keeping the steam avatars a roster shows", () => {
|
|
97
|
+
test("asks steam for a hash it has never seen, and answers with it", async () => {
|
|
98
|
+
const steam = fakeSteam({
|
|
99
|
+
answer: () => [
|
|
100
|
+
summaryOf(ALICE, ALICE_HASH),
|
|
101
|
+
],
|
|
102
|
+
});
|
|
103
|
+
const avatars = createSteamAvatars({
|
|
104
|
+
steamIdOf: identity,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
expect(
|
|
108
|
+
await avatars.resolve(steam.context, [
|
|
109
|
+
ALICE,
|
|
110
|
+
]),
|
|
111
|
+
).toEqual(
|
|
112
|
+
new Map([
|
|
113
|
+
[
|
|
114
|
+
ALICE,
|
|
115
|
+
ALICE_HASH,
|
|
116
|
+
],
|
|
117
|
+
]),
|
|
118
|
+
);
|
|
119
|
+
expect(steam.calls).toEqual([
|
|
120
|
+
[
|
|
121
|
+
ALICE,
|
|
122
|
+
],
|
|
123
|
+
]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("answers the second time without asking steam again", async () => {
|
|
127
|
+
const steam = fakeSteam({
|
|
128
|
+
answer: () => [
|
|
129
|
+
summaryOf(ALICE, ALICE_HASH),
|
|
130
|
+
],
|
|
131
|
+
});
|
|
132
|
+
const avatars = createSteamAvatars({
|
|
133
|
+
steamIdOf: identity,
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
await avatars.resolve(steam.context, [
|
|
137
|
+
ALICE,
|
|
138
|
+
]);
|
|
139
|
+
|
|
140
|
+
expect(
|
|
141
|
+
(
|
|
142
|
+
await avatars.resolve(steam.context, [
|
|
143
|
+
ALICE,
|
|
144
|
+
])
|
|
145
|
+
).get(ALICE),
|
|
146
|
+
).toBe(ALICE_HASH);
|
|
147
|
+
expect(steam.calls.length).toBe(1);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("asks only for the ids it is missing, and still answers for the cached one", async () => {
|
|
151
|
+
const steam = fakeSteam({
|
|
152
|
+
answer: (steamIds) => {
|
|
153
|
+
return steamIds.map((steamId) => {
|
|
154
|
+
return summaryOf(steamId, steamId.endsWith("1") ? ALICE_HASH : BOB_HASH);
|
|
155
|
+
});
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
const avatars = createSteamAvatars({
|
|
159
|
+
steamIdOf: identity,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
await avatars.resolve(steam.context, [
|
|
163
|
+
ALICE,
|
|
164
|
+
]);
|
|
165
|
+
|
|
166
|
+
const hashes = await avatars.resolve(steam.context, [
|
|
167
|
+
ALICE,
|
|
168
|
+
BOB,
|
|
169
|
+
]);
|
|
170
|
+
|
|
171
|
+
expect(steam.calls.at(1)).toEqual([
|
|
172
|
+
BOB,
|
|
173
|
+
]);
|
|
174
|
+
expect(hashes.get(ALICE)).toBe(ALICE_HASH);
|
|
175
|
+
expect(hashes.get(BOB)).toBe(BOB_HASH);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("asks steam again once the cached hash is six hours old", async () => {
|
|
179
|
+
setSystemTime(new Date(0));
|
|
180
|
+
|
|
181
|
+
const steam = fakeSteam({
|
|
182
|
+
answer: () => [
|
|
183
|
+
summaryOf(ALICE, ALICE_HASH),
|
|
184
|
+
],
|
|
185
|
+
});
|
|
186
|
+
const avatars = createSteamAvatars({
|
|
187
|
+
steamIdOf: identity,
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
await avatars.resolve(steam.context, [
|
|
191
|
+
ALICE,
|
|
192
|
+
]);
|
|
193
|
+
|
|
194
|
+
setSystemTime(new Date(STEAM_AVATAR_CACHE_TTL_MS - 1));
|
|
195
|
+
|
|
196
|
+
await avatars.resolve(steam.context, [
|
|
197
|
+
ALICE,
|
|
198
|
+
]);
|
|
199
|
+
|
|
200
|
+
expect(steam.calls.length).toBe(1);
|
|
201
|
+
|
|
202
|
+
setSystemTime(new Date(STEAM_AVATAR_CACHE_TTL_MS));
|
|
203
|
+
|
|
204
|
+
expect(
|
|
205
|
+
(
|
|
206
|
+
await avatars.resolve(steam.context, [
|
|
207
|
+
ALICE,
|
|
208
|
+
])
|
|
209
|
+
).get(ALICE),
|
|
210
|
+
).toBe(ALICE_HASH);
|
|
211
|
+
expect(steam.calls.length).toBe(2);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("never asks steam about a player the game does not map to a steam id", async () => {
|
|
215
|
+
const steam = fakeSteam({});
|
|
216
|
+
const avatars = createSteamAvatars({
|
|
217
|
+
steamIdOf: identity,
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
expect(
|
|
221
|
+
await avatars.resolve(steam.context, [
|
|
222
|
+
"gdk_2535000000000001",
|
|
223
|
+
]),
|
|
224
|
+
).toEqual(new Map());
|
|
225
|
+
expect(steam.calls).toEqual([]);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("answers with nothing while the platform has no steam key", async () => {
|
|
229
|
+
const steam = fakeSteam({
|
|
230
|
+
key: null,
|
|
231
|
+
});
|
|
232
|
+
const avatars = createSteamAvatars({
|
|
233
|
+
steamIdOf: identity,
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
expect(
|
|
237
|
+
await avatars.resolve(steam.context, [
|
|
238
|
+
ALICE,
|
|
239
|
+
]),
|
|
240
|
+
).toEqual(new Map());
|
|
241
|
+
expect(steam.calls).toEqual([]);
|
|
242
|
+
expect(steam.warnings).toEqual([]);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("answers with nothing and warns once when steam refuses", async () => {
|
|
246
|
+
const steam = fakeSteam({
|
|
247
|
+
answer: () => {
|
|
248
|
+
throw new Error("429 too many requests");
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
const avatars = createSteamAvatars({
|
|
252
|
+
steamIdOf: identity,
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
expect(
|
|
256
|
+
await avatars.resolve(steam.context, [
|
|
257
|
+
ALICE,
|
|
258
|
+
]),
|
|
259
|
+
).toEqual(new Map());
|
|
260
|
+
expect(steam.warnings.length).toBe(1);
|
|
261
|
+
|
|
262
|
+
await avatars.resolve(steam.context, [
|
|
263
|
+
ALICE,
|
|
264
|
+
]);
|
|
265
|
+
|
|
266
|
+
expect(steam.warnings.length).toBe(1);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test("never writes the api key into the warning it logs", async () => {
|
|
270
|
+
const steam = fakeSteam({
|
|
271
|
+
key: "top-secret-key",
|
|
272
|
+
answer: () => {
|
|
273
|
+
throw new Error("steam said no");
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
const avatars = createSteamAvatars({
|
|
277
|
+
steamIdOf: identity,
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
await avatars.resolve(steam.context, [
|
|
281
|
+
ALICE,
|
|
282
|
+
]);
|
|
283
|
+
|
|
284
|
+
expect(JSON.stringify(steam.warnings)).not.toContain("top-secret-key");
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("keeps the roster moving when steam sends a profile with no usable picture", async () => {
|
|
288
|
+
const steam = fakeSteam({
|
|
289
|
+
answer: () => [
|
|
290
|
+
{
|
|
291
|
+
steamid: ALICE,
|
|
292
|
+
avatarfull: "https://example.com/none.png",
|
|
293
|
+
},
|
|
294
|
+
],
|
|
295
|
+
});
|
|
296
|
+
const avatars = createSteamAvatars({
|
|
297
|
+
steamIdOf: identity,
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
expect(
|
|
301
|
+
await avatars.resolve(steam.context, [
|
|
302
|
+
ALICE,
|
|
303
|
+
]),
|
|
304
|
+
).toEqual(new Map());
|
|
305
|
+
});
|
|
306
|
+
});
|