452 lines
17 KiB
TypeScript
452 lines
17 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
freshTelemetry,
|
|
selectServerObservation,
|
|
type ServerObservation
|
|
} from '../../src/lib/server-observation.js';
|
|
import { gameEvent } from '../helpers/telemetry.js';
|
|
|
|
const now = Date.parse('2026-09-27T12:00:00Z');
|
|
const at = (offset = 0) => new Date(now + offset).toISOString();
|
|
const report = (offset = 0, players = 3) => ({
|
|
...gameEvent('heartbeat', 15, players, 'managed'),
|
|
receivedAt: at(offset)
|
|
});
|
|
function server(changes: Partial<ServerObservation> = {}): ServerObservation {
|
|
return {
|
|
name: 'managed',
|
|
health: 'online',
|
|
container_state: 'running',
|
|
container_checked_at: at(),
|
|
last_players: 4,
|
|
last_map: 'zombie_theater',
|
|
last_gametype: 'zom',
|
|
last_seen_at: at(),
|
|
last_error: null,
|
|
roster: [],
|
|
...changes
|
|
};
|
|
}
|
|
|
|
describe('shared server observation', () => {
|
|
it('tolerates only the first transient failure while retaining diagnostics and expiring evidence', () => {
|
|
for (const reason of ['timeout', 'send-error']) {
|
|
const raw = { ...server(), last_error: `${reason}:no reply`, rcon_failures: 1 };
|
|
const selected = selectServerObservation(server({ rcon_status: raw }), undefined, now);
|
|
expect(selected).toMatchObject({
|
|
health: 'online',
|
|
last_error: null,
|
|
rcon_status: raw
|
|
});
|
|
expect(selectServerObservation(selected, undefined, now + 90_000).health).toBe(
|
|
'online'
|
|
);
|
|
expect(selectServerObservation(selected, undefined, now + 90_001).health).toBe('stale');
|
|
expect(selectServerObservation(selected, undefined, now + 120_001).health).toBe(
|
|
'offline'
|
|
);
|
|
expect(
|
|
selectServerObservation(
|
|
server({ rcon_status: { ...raw, rcon_failures: 2 } }),
|
|
undefined,
|
|
now
|
|
)
|
|
).toMatchObject({ health: 'stale', last_error: raw.last_error });
|
|
}
|
|
for (const reason of ['no-config', 'no-port', 'bad-reply', 'no-players']) {
|
|
expect(
|
|
selectServerObservation(
|
|
server({ last_error: `${reason}:failed`, rcon_failures: 1 }),
|
|
undefined,
|
|
now
|
|
).health
|
|
).toBe('stale');
|
|
}
|
|
expect(
|
|
selectServerObservation(
|
|
server({ last_error: 'timeout:legacy failure' }),
|
|
undefined,
|
|
now
|
|
).health
|
|
).toBe('stale');
|
|
});
|
|
|
|
it('accepts telemetry and container evidence through 90 seconds, then expires each independently', () => {
|
|
expect(selectServerObservation(server(), report(), now + 90_000)).toMatchObject({
|
|
health: 'online',
|
|
status_source: 'telemetry'
|
|
});
|
|
expect(selectServerObservation(server(), report(90_001), now + 90_001).health).toBe(
|
|
'stale'
|
|
);
|
|
const current = server({ container_checked_at: at(90_001) });
|
|
expect(selectServerObservation(current, report(), now + 90_001).health).toBe('stale');
|
|
expect(freshTelemetry(report(-90_000), now)).toBe(true);
|
|
expect(freshTelemetry(report(-90_001), now)).toBe(false);
|
|
});
|
|
|
|
it('selects the telemetry count and roster together and keeps raw RCON evidence', () => {
|
|
const raw = server({ last_error: 'timeout' });
|
|
const selected = selectServerObservation(server({ rcon_status: raw }), report(), now);
|
|
expect(selected).toMatchObject({
|
|
health: 'online',
|
|
status_source: 'telemetry',
|
|
last_players: 3,
|
|
last_error: null
|
|
});
|
|
expect(selected.roster).toHaveLength(3);
|
|
expect(selected.roster?.[0]).toMatchObject({ ping: null, score: 5000 });
|
|
expect(selected.rcon_status).toBe(raw);
|
|
});
|
|
|
|
it('supplements telemetry members with GUID-matched pings without changing scores or names', () => {
|
|
const telemetry = report();
|
|
const raw = server({
|
|
roster: [
|
|
{ ...telemetry.players[1], num: 2, score: 10, ping: 0, name: 'Old name' },
|
|
{ ...telemetry.players[0], num: 3, score: 20, ping: 999 },
|
|
{
|
|
...telemetry.players[2],
|
|
num: 4,
|
|
score: 30,
|
|
ping: 45,
|
|
guid: 'different-player',
|
|
name: 'Different player'
|
|
}
|
|
]
|
|
});
|
|
const selected = selectServerObservation(server({ rcon_status: raw }), telemetry, now);
|
|
expect(selected.roster).toEqual(
|
|
telemetry.players.map((player, i) => ({
|
|
name: player.name,
|
|
guid: player.guid,
|
|
num: null,
|
|
score: 5000,
|
|
ping: [999, 0, null][i]
|
|
}))
|
|
);
|
|
expect(selected.last_players).toBe(3);
|
|
expect(selected.rcon_status).toBe(raw);
|
|
// A new telemetry report must not renew the RCON sample's age.
|
|
const expired = selectServerObservation(
|
|
{ ...selected, container_checked_at: at(90_001) },
|
|
report(90_001),
|
|
now + 90_001
|
|
);
|
|
expect(expired.health).toBe('online');
|
|
expect(expired.roster?.map((player) => player.ping)).toEqual([null, null, null]);
|
|
});
|
|
|
|
it('prefers telemetry ping, including zero, and fills absent or null readings from RCON', () => {
|
|
const telemetry = report();
|
|
telemetry.players[0].ping = 0;
|
|
telemetry.players[1].ping = null;
|
|
const raw = server({
|
|
roster: telemetry.players.map((player) => ({ ...player, num: 0, score: 10, ping: 42 }))
|
|
});
|
|
expect(
|
|
selectServerObservation(server({ rcon_status: raw }), telemetry, now).roster?.map(
|
|
(player) => player.ping
|
|
)
|
|
).toEqual([0, 42, 42]);
|
|
});
|
|
|
|
it.each([
|
|
{ last_seen_at: at(-90_001) },
|
|
{ last_seen_at: at(2000) },
|
|
{ last_seen_at: null },
|
|
{ last_error: 'timeout', rcon_failures: 2 },
|
|
{ last_map: 'other-map' }
|
|
])('does not supplement pings from unusable RCON evidence: %j', (changes) => {
|
|
const telemetry = report();
|
|
const raw = server({
|
|
...changes,
|
|
roster: [{ ...telemetry.players[0], num: 0, score: 100, ping: 42 }]
|
|
});
|
|
expect(
|
|
selectServerObservation(server({ rcon_status: raw }), telemetry, now).roster?.[0].ping
|
|
).toBeNull();
|
|
});
|
|
|
|
it('requires RCON samples after the latest lifecycle and tolerates one transient failure', () => {
|
|
const telemetry = report();
|
|
const raw = server({
|
|
last_seen_at: at(-90_000),
|
|
roster: [{ ...telemetry.players[0], num: 0, score: 100, ping: 42 }]
|
|
});
|
|
expect(
|
|
selectServerObservation(server({ rcon_status: raw }), telemetry, now).roster?.[0].ping
|
|
).toBe(42);
|
|
expect(
|
|
selectServerObservation(
|
|
server({ rcon_status: raw, telemetry_after: at(-90_000) }),
|
|
telemetry,
|
|
now
|
|
).roster?.[0].ping
|
|
).toBeNull();
|
|
expect(
|
|
selectServerObservation(
|
|
server({ rcon_status: { ...raw, last_error: 'timeout', rcon_failures: 1 } }),
|
|
telemetry,
|
|
now
|
|
).roster?.[0].ping
|
|
).toBe(42);
|
|
});
|
|
|
|
it.each(['duplicate', 'null', 'negative', 'invalid'])('rejects %s ping matches', (mode) => {
|
|
const telemetry = report();
|
|
const player = {
|
|
...telemetry.players[0],
|
|
num: 0,
|
|
score: 100,
|
|
ping: 42 as number | null,
|
|
guid: telemetry.players[0].guid as string | null
|
|
};
|
|
if (mode === 'null') player.ping = null;
|
|
if (mode === 'negative') player.ping = -1;
|
|
if (mode === 'invalid') player.ping = NaN;
|
|
const raw = server({
|
|
roster: mode === 'duplicate' ? [player, { ...player, ping: 50 }] : [player]
|
|
});
|
|
expect(
|
|
selectServerObservation(server({ rcon_status: raw }), telemetry, now).roster?.[0].ping
|
|
).toBeNull();
|
|
});
|
|
|
|
it.each(['t5', 't6'])(
|
|
'does not match %s players by name when GUIDs differ or are missing',
|
|
(game) => {
|
|
const telemetry = report(0, 2);
|
|
telemetry.players[0].name = 'EyelessGreem';
|
|
telemetry.players[1].name = '_frxn.3';
|
|
const raw = server({
|
|
roster: [
|
|
{ num: 0, score: 940, ping: 91, guid: '6C4C20', name: 'EyelessGreem' },
|
|
{ num: 1, score: 150, ping: 151, guid: null, name: '_frxn.3' }
|
|
]
|
|
});
|
|
const selected = selectServerObservation(
|
|
server({ game, rcon_status: raw }),
|
|
telemetry,
|
|
now
|
|
);
|
|
expect(selected.roster?.map((player) => player.ping)).toEqual([null, null]);
|
|
}
|
|
);
|
|
|
|
it.each([
|
|
['6C4C20', '7097376'],
|
|
['6c45dd', '7095773'],
|
|
['00006C4C20', '0007097376'],
|
|
['0x6C45DD', '7095773'],
|
|
['10', '16'],
|
|
['20000000000001', '9007199254740993']
|
|
])('matches T6 hex GUID %s to decimal %s despite different names', (hex, decimal) => {
|
|
const telemetry = report(0, 1);
|
|
telemetry.players[0].guid = decimal;
|
|
const raw = server({
|
|
roster: [{ num: 0, score: 100, ping: 91, guid: hex, name: 'Previous name' }]
|
|
});
|
|
const selected = selectServerObservation(
|
|
server({ game: 't6', rcon_status: raw }),
|
|
telemetry,
|
|
now
|
|
);
|
|
expect(selected.roster?.[0]).toMatchObject({
|
|
guid: decimal,
|
|
name: 'Player 1',
|
|
ping: 91,
|
|
score: 5000
|
|
});
|
|
expect(selected.rcon_status?.roster?.[0].guid).toBe(hex);
|
|
});
|
|
|
|
it.each(['t5', undefined])('keeps decimal-looking GUIDs literal for game %s', (game) => {
|
|
const telemetry = report(0, 2);
|
|
telemetry.players[0].guid = '10';
|
|
telemetry.players[1].guid = '16';
|
|
const raw = server({
|
|
roster: [{ num: 0, score: 100, ping: 91, guid: '10', name: 'Previous name' }]
|
|
});
|
|
expect(
|
|
selectServerObservation(server({ game, rcon_status: raw }), telemetry, now).roster?.map(
|
|
(player) => player.ping
|
|
)
|
|
).toEqual([91, null]);
|
|
});
|
|
|
|
it('treats digit-only T6 GUIDs as hexadecimal regardless of player names', () => {
|
|
const telemetry = report(0, 2);
|
|
telemetry.players[0].guid = '10';
|
|
telemetry.players[1].guid = '16';
|
|
const raw = server({
|
|
roster: [{ num: 0, score: 100, ping: 91, guid: '10', name: 'Player 1' }]
|
|
});
|
|
expect(
|
|
selectServerObservation(
|
|
server({ game: 't6', rcon_status: raw }),
|
|
telemetry,
|
|
now
|
|
).roster?.map((player) => player.ping)
|
|
).toEqual([null, 91]);
|
|
});
|
|
|
|
it('rejects duplicate RCON GUIDs after normalization', () => {
|
|
const telemetry = report(0, 1);
|
|
telemetry.players[0].guid = '7097376';
|
|
const raw = server({
|
|
roster: [
|
|
{ num: 0, score: 100, ping: 91, guid: '6C4C20', name: 'Player 1' },
|
|
{ num: 1, score: 100, ping: 42, guid: '006c4c20', name: 'Other player' }
|
|
]
|
|
});
|
|
expect(
|
|
selectServerObservation(server({ game: 't6', rcon_status: raw }), telemetry, now)
|
|
.roster?.[0].ping
|
|
).toBeNull();
|
|
});
|
|
|
|
it('matches GUIDs regardless of conflicting names', () => {
|
|
const telemetry = report(0, 2);
|
|
const raw = server({
|
|
roster: [
|
|
{
|
|
num: 0,
|
|
score: 100,
|
|
ping: 42,
|
|
guid: telemetry.players[1].guid,
|
|
name: telemetry.players[0].name
|
|
},
|
|
{
|
|
num: 1,
|
|
score: 100,
|
|
ping: 99,
|
|
guid: 'another-guid',
|
|
name: telemetry.players[1].name
|
|
}
|
|
]
|
|
});
|
|
expect(
|
|
selectServerObservation(server({ rcon_status: raw }), telemetry, now).roster?.map(
|
|
(player) => player.ping
|
|
)
|
|
).toEqual([null, 42]);
|
|
});
|
|
|
|
it('uses the latest HTTP or streamed telemetry and falls back without extending freshness', () => {
|
|
const original = server();
|
|
const selected = selectServerObservation(
|
|
server({ rcon_status: original, telemetry: report(0, 2) }),
|
|
report(-1000),
|
|
now
|
|
);
|
|
expect(selected.last_players).toBe(2);
|
|
const fallback = selectServerObservation(
|
|
{
|
|
...selected,
|
|
container_checked_at: at(91_000),
|
|
rcon_status: { ...original, last_seen_at: at(40_000) }
|
|
},
|
|
undefined,
|
|
now + 91_000
|
|
);
|
|
expect(fallback).toMatchObject({
|
|
status_source: 'rcon',
|
|
health: 'online',
|
|
last_players: 4,
|
|
last_seen_at: at(40_000)
|
|
});
|
|
});
|
|
|
|
it('displays telemetry pings and clears missing readings on subsequent reports', () => {
|
|
const telemetry = report(0, 4);
|
|
telemetry.players = telemetry.players.map((player, i) => ({
|
|
...player,
|
|
ping: [0, 42, 999, null][i]
|
|
}));
|
|
const selected = selectServerObservation(server(), telemetry, now);
|
|
expect(selected.roster?.map((player) => player.ping)).toEqual([0, 42, 999, null]);
|
|
expect(
|
|
selectServerObservation(selected, report(1, 4), now + 1).roster?.map(
|
|
(player) => player.ping
|
|
)
|
|
).toEqual([null, null, null, null]);
|
|
const start = {
|
|
...gameEvent('game_start', 0, 1, 'managed'),
|
|
event: 'game_start' as const,
|
|
receivedAt: at(),
|
|
players: [{ name: 'Player 1', guid: '123', ping: 25 }]
|
|
};
|
|
expect(selectServerObservation(server(), start, now).roster?.[0]).toMatchObject({
|
|
score: null,
|
|
ping: 25
|
|
});
|
|
});
|
|
|
|
it.each(['starting', 'restarting', 'unhealthy', 'exited'])(
|
|
'telemetry cannot override container state %s',
|
|
(state) => {
|
|
const selected = selectServerObservation(
|
|
server({
|
|
health: state === 'starting' || state === 'restarting' ? 'starting' : 'offline',
|
|
container_state: state
|
|
}),
|
|
report(),
|
|
now
|
|
);
|
|
expect(selected.health).not.toBe('online');
|
|
expect(selected.last_players).toBeNull();
|
|
expect(selected.roster).toBeNull();
|
|
}
|
|
);
|
|
|
|
it('uses fresh RCON with unknown container state, but does not substitute telemetry or extend freshness', () => {
|
|
const raw = server({ container_state: 'unknown', container_checked_at: at(-60_000) });
|
|
expect(selectServerObservation(raw, report(), now)).toMatchObject({
|
|
health: 'online',
|
|
last_players: 4,
|
|
status_source: 'rcon'
|
|
});
|
|
expect(selectServerObservation(raw, report(91_000), now + 91_000).health).toBe('stale');
|
|
expect(selectServerObservation(raw, report(121_000), now + 121_000).health).toBe('offline');
|
|
expect(
|
|
selectServerObservation({ ...raw, telemetry_after: at() }, report(), now).health
|
|
).toBe('offline');
|
|
expect(selectServerObservation({ ...raw, last_seen_at: null }, report(), now).health).toBe(
|
|
'offline'
|
|
);
|
|
});
|
|
|
|
it('requires fresh container evidence and reports after the latest lifecycle submission', () => {
|
|
expect(
|
|
selectServerObservation(server({ container_checked_at: at(-91_000) }), report(), now)
|
|
.health
|
|
).not.toBe('online');
|
|
expect(
|
|
selectServerObservation(server({ health: 'provisioning' }), report(), now).health
|
|
).toBe('provisioning');
|
|
expect(
|
|
selectServerObservation(server({ telemetry_after: at() }), report(-1), now).health
|
|
).toBe('offline');
|
|
expect(freshTelemetry(report(), now, 'invalid')).toBe(false);
|
|
expect(freshTelemetry(report(2000), now)).toBe(false);
|
|
expect(freshTelemetry(report(-91_000), now)).toBe(false);
|
|
expect(freshTelemetry({ ...report(), players: [{}] } as never, now)).toBe(false);
|
|
});
|
|
|
|
it('uses game-start rosters without inventing scores and preserves match times on an empty report', () => {
|
|
const start = { ...gameEvent('game_start', 0, 2, 'managed'), receivedAt: at() };
|
|
const active = selectServerObservation(server(), start, now);
|
|
expect(active.roster?.[0]).toMatchObject({ score: null, ping: null });
|
|
expect(active.game_started_at).toBe(at());
|
|
expect(
|
|
selectServerObservation({ ...active, game_ended_at: at() }, report(1, 0), now + 1)
|
|
).toMatchObject({
|
|
last_players: 0,
|
|
roster: [],
|
|
game_started_at: at(),
|
|
game_ended_at: at()
|
|
});
|
|
});
|
|
});
|