733 lines
29 KiB
TypeScript
733 lines
29 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { get } from 'svelte/store';
|
|
import { freshServer, matchesFleet, type FleetServer } from '../../src/lib/fleet-overview.js';
|
|
import type { ServerStatus } from '../../src/lib/ui/stores/servers.js';
|
|
import type { GameEvent, ServerTiming } from '../../src/lib/telemetry.js';
|
|
import { gameEvent } from '../helpers/telemetry.js';
|
|
|
|
vi.mock('$app/environment', () => ({ browser: true }));
|
|
|
|
const epoch = Date.parse('2026-09-19T12:00:00Z');
|
|
let source: EventTarget & { close: () => void };
|
|
let stop: (() => void) | undefined;
|
|
let snapshot: { serverNow: number; servers: FleetServer[]; integrations: object };
|
|
let frames: Map<number, FrameRequestCallback>;
|
|
|
|
function flushFrame() {
|
|
const callbacks = [...frames.values()];
|
|
frames.clear();
|
|
for (const callback of callbacks) callback(performance.now());
|
|
}
|
|
|
|
function server(timestamp = epoch, changes: Partial<FleetServer> = {}): FleetServer {
|
|
return {
|
|
name: 't5_zombie_theater_1',
|
|
game: 't5',
|
|
map: 'zombie_theater',
|
|
last_players: 0,
|
|
last_map: 'zombie_theater',
|
|
last_gametype: 'zom',
|
|
last_seen_at: new Date(timestamp).toISOString(),
|
|
last_error: null,
|
|
roster: [],
|
|
health: 'online',
|
|
held: [],
|
|
active_since: null,
|
|
inactive_since: new Date(epoch).toISOString(),
|
|
game_started_at: null,
|
|
game_ended_at: null,
|
|
...changes
|
|
};
|
|
}
|
|
|
|
function status(timestamp: number, changes: Partial<ServerStatus> = {}): ServerStatus {
|
|
return {
|
|
server: 't5_zombie_theater_1',
|
|
players: 0,
|
|
map: 'zombie_theater',
|
|
gametype: 'zom',
|
|
roster: [],
|
|
health: 'online',
|
|
lastSeenAt: new Date(timestamp).toISOString(),
|
|
lastError: null,
|
|
endpoint: null,
|
|
reason: null,
|
|
activeSince: null,
|
|
inactiveSince: new Date(epoch).toISOString(),
|
|
gameStartedAt: null,
|
|
gameEndedAt: null,
|
|
...changes
|
|
};
|
|
}
|
|
|
|
function emit(serverNow: number, entries: ServerStatus[]) {
|
|
source.dispatchEvent(
|
|
new MessageEvent('server-status', { data: JSON.stringify({ serverNow, entries }) })
|
|
);
|
|
flushFrame();
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
vi.useFakeTimers({
|
|
toFake: [
|
|
'Date',
|
|
'performance',
|
|
'setTimeout',
|
|
'clearTimeout',
|
|
'setInterval',
|
|
'clearInterval'
|
|
]
|
|
});
|
|
vi.setSystemTime(epoch);
|
|
frames = new Map();
|
|
let frameId = 0;
|
|
vi.stubGlobal(
|
|
'requestAnimationFrame',
|
|
vi.fn((callback: FrameRequestCallback) => {
|
|
frames.set(++frameId, callback);
|
|
return frameId;
|
|
})
|
|
);
|
|
vi.stubGlobal(
|
|
'cancelAnimationFrame',
|
|
vi.fn((id: number) => {
|
|
frames.delete(id);
|
|
})
|
|
);
|
|
snapshot = { serverNow: epoch, servers: [server()], integrations: {} };
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn(
|
|
async (url: string) =>
|
|
new Response(JSON.stringify(url === '/api/servers' ? snapshot : { operations: [] }))
|
|
)
|
|
);
|
|
vi.stubGlobal(
|
|
'EventSource',
|
|
class extends EventTarget {
|
|
constructor() {
|
|
super();
|
|
// eslint-disable-next-line @typescript-eslint/no-this-alias -- Expose the constructed EventSource mock so tests can dispatch stream events.
|
|
source = this;
|
|
}
|
|
close() {}
|
|
}
|
|
);
|
|
});
|
|
|
|
afterEach(() => {
|
|
stop?.();
|
|
stop = undefined;
|
|
vi.useRealTimers();
|
|
vi.unstubAllGlobals();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
async function setup() {
|
|
const clock = await import('../../src/lib/ui/stores/servers.js');
|
|
const fleet = await import('../../src/lib/ui/stores/fleet.js');
|
|
stop = clock.startServerStream();
|
|
await fleet.refreshFleet();
|
|
return { ...clock, ...fleet };
|
|
}
|
|
|
|
function queueEvent(type: string, data: object) {
|
|
source.dispatchEvent(new MessageEvent(type, { data: JSON.stringify(data) }));
|
|
}
|
|
|
|
describe('animation-frame stream batching', () => {
|
|
it('replaces snapshot capacity with live values and honors explicit unavailable samples', async () => {
|
|
const at = new Date(epoch).toISOString();
|
|
snapshot.servers = [server(epoch, { capacity: 4, capacity_observed_at: at })];
|
|
const { fleetServers } = await setup();
|
|
expect(get(fleetServers)[0]).toMatchObject({ capacity: 4, capacity_observed_at: at });
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
emit(epoch + 1000, [
|
|
status(epoch + 1000, {
|
|
capacity: 8,
|
|
capacityObservedAt: new Date(epoch + 1000).toISOString()
|
|
})
|
|
]);
|
|
expect(get(fleetServers)[0].capacity).toBe(8);
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
emit(epoch + 2000, [status(epoch + 2000, { capacity: null, capacityObservedAt: null })]);
|
|
expect(get(fleetServers)[0]).toMatchObject({ capacity: null, capacity_observed_at: null });
|
|
});
|
|
|
|
it('expires capacity independently of fresh rosters and rejects the previous lifecycle limit', async () => {
|
|
const at = (offset = 0) => new Date(epoch + offset).toISOString();
|
|
snapshot.servers = [
|
|
server(epoch, {
|
|
capacity: 4,
|
|
capacity_observed_at: at(-90_000),
|
|
container_state: 'running',
|
|
container_checked_at: at()
|
|
})
|
|
];
|
|
const { fleetServers } = await setup();
|
|
expect(get(fleetServers)[0].capacity).toBe(4);
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
queueEvent('server-telemetry', {
|
|
serverNow: epoch + 1000,
|
|
entries: [{ ...gameEvent('heartbeat', 15, 3), receivedAt: at(1000) }]
|
|
});
|
|
flushFrame();
|
|
expect(get(fleetServers)[0]).toMatchObject({
|
|
health: 'online',
|
|
last_players: 3,
|
|
capacity: null,
|
|
capacity_observed_at: null
|
|
});
|
|
emit(epoch + 1000, [
|
|
status(epoch + 1000, { capacity: 4, capacityObservedAt: at(), telemetryAfter: at(1) })
|
|
]);
|
|
expect(get(fleetServers)[0]).toMatchObject({ capacity: null, capacity_observed_at: null });
|
|
});
|
|
|
|
it('publishes each changed store once for a burst and preserves lifecycle evidence through heartbeats', async () => {
|
|
const stores = await setup();
|
|
emit(epoch, [status(epoch), status(epoch, { server: 'two' })]);
|
|
const before = get(stores.statusByServer);
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
const watched = [
|
|
stores.statusByServer,
|
|
stores.statusReceivedAt,
|
|
stores.telemetryByServer,
|
|
stores.matchEventsByServer,
|
|
stores.uiNow
|
|
];
|
|
const changes = watched.map(() => vi.fn());
|
|
const unsubscribe = watched.map((store, index) => store.subscribe(changes[index]));
|
|
changes.forEach((change) => change.mockClear());
|
|
vi.mocked(requestAnimationFrame).mockClear();
|
|
try {
|
|
for (let i = 0; i < 100; i++) {
|
|
queueEvent('server-status', {
|
|
delta: true,
|
|
serverNow: epoch + 1000 + i,
|
|
entries: [status(epoch + 1000, { players: i })]
|
|
});
|
|
queueEvent('server-telemetry', {
|
|
serverNow: epoch + 1000 + i,
|
|
entries: [
|
|
{
|
|
...gameEvent(i === 0 ? 'game_start' : 'heartbeat', i, 1),
|
|
receivedAt: new Date(epoch + 1000 + i).toISOString()
|
|
}
|
|
]
|
|
});
|
|
}
|
|
expect(requestAnimationFrame).toHaveBeenCalledTimes(1);
|
|
changes.forEach((change) => expect(change).not.toHaveBeenCalled());
|
|
expect(get(stores.statusByServer)).toBe(before);
|
|
expect(before.get('t5_zombie_theater_1')?.players).toBe(0);
|
|
flushFrame();
|
|
changes.forEach((change) => expect(change).toHaveBeenCalledTimes(1));
|
|
expect(get(stores.statusByServer).size).toBe(2);
|
|
expect(get(stores.statusByServer).get('t5_zombie_theater_1')?.players).toBe(99);
|
|
expect(get(stores.telemetryByServer).get('t5_zombie_theater_1')).toMatchObject({
|
|
event: 'heartbeat',
|
|
round: 99
|
|
});
|
|
expect(get(stores.matchEventsByServer).get('t5_zombie_theater_1')?.event).toBe(
|
|
'game_start'
|
|
);
|
|
|
|
queueEvent('server-telemetry', {
|
|
entries: [
|
|
{
|
|
...gameEvent('heartbeat', 100, 1),
|
|
receivedAt: new Date(epoch + 1100).toISOString()
|
|
}
|
|
]
|
|
});
|
|
flushFrame();
|
|
expect(requestAnimationFrame).toHaveBeenCalledTimes(2);
|
|
expect(changes[2]).toHaveBeenCalledTimes(2);
|
|
expect(changes[3]).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
unsubscribe.forEach((unsubscribe) => unsubscribe());
|
|
}
|
|
});
|
|
|
|
it('applies snapshots and subsequent deltas in order, including empty snapshots', async () => {
|
|
const { statusByServer, telemetryByServer, matchEventsByServer } = await setup();
|
|
const telemetry = (event: GameEvent['event'], name: string) => ({
|
|
...gameEvent(event, 1, 1, name),
|
|
receivedAt: new Date(epoch).toISOString()
|
|
});
|
|
emit(epoch, [status(epoch, { server: 'old' })]);
|
|
queueEvent('server-telemetry', { entries: [telemetry('game_start', 'old')] });
|
|
flushFrame();
|
|
|
|
queueEvent('server-status', {
|
|
delta: true,
|
|
entries: [status(epoch, { server: 'discarded' })]
|
|
});
|
|
queueEvent('server-status', { entries: [status(epoch, { server: 'two' })] });
|
|
queueEvent('server-status', { delta: true, entries: [status(epoch, { server: 'three' })] });
|
|
queueEvent('server-telemetry', { entries: [telemetry('game_end', 'discarded')] });
|
|
queueEvent('server-telemetry', {
|
|
snapshot: true,
|
|
entries: [telemetry('heartbeat', 'two')]
|
|
});
|
|
queueEvent('server-telemetry', { entries: [telemetry('game_end', 'three')] });
|
|
queueEvent('server-telemetry', { entries: [telemetry('heartbeat', 'three')] });
|
|
flushFrame();
|
|
expect([...get(statusByServer).keys()]).toEqual(['two', 'three']);
|
|
expect([...get(telemetryByServer).keys()]).toEqual(['two', 'three']);
|
|
expect([...get(matchEventsByServer).keys()]).toEqual(['three']);
|
|
expect(get(matchEventsByServer).get('three')?.event).toBe('game_end');
|
|
expect(get(telemetryByServer).get('three')?.event).toBe('heartbeat');
|
|
|
|
queueEvent('server-status', { entries: [] });
|
|
queueEvent('server-telemetry', { snapshot: true, entries: [] });
|
|
flushFrame();
|
|
expect(get(statusByServer).size).toBe(0);
|
|
expect(get(telemetryByServer).size).toBe(0);
|
|
expect(get(matchEventsByServer).size).toBe(0);
|
|
});
|
|
|
|
it('retains event arrival times so buffered status cannot overwrite a newer HTTP snapshot', async () => {
|
|
const { fleetServers, refreshFleet, statusByServer, statusReceivedAt } = await setup();
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
queueEvent('server-status', {
|
|
delta: true,
|
|
serverNow: epoch + 1000,
|
|
entries: [status(epoch + 1000, { players: 1 })]
|
|
});
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
snapshot = {
|
|
...snapshot,
|
|
serverNow: epoch + 2000,
|
|
servers: [server(epoch + 2000, { last_players: 4 })]
|
|
};
|
|
await refreshFleet();
|
|
queueEvent('server-status', {
|
|
delta: true,
|
|
serverNow: epoch + 2000,
|
|
entries: [status(epoch + 2000, { server: 'two' })]
|
|
});
|
|
flushFrame();
|
|
expect(get(statusByServer).get('t5_zombie_theater_1')?.receivedAt).toBe(epoch + 1000);
|
|
expect(get(statusReceivedAt)).toBe(epoch + 2000);
|
|
expect(get(fleetServers)[0].last_players).toBe(4);
|
|
});
|
|
|
|
it('accounts for frame delay without rewinding the server clock', async () => {
|
|
const { uiNow, statusByServer } = await setup();
|
|
vi.stubGlobal('document', { visibilityState: 'hidden' });
|
|
queueEvent('server-status', { serverNow: epoch + 5000, entries: [status(epoch + 5000)] });
|
|
await vi.advanceTimersByTimeAsync(2000);
|
|
queueEvent('server-telemetry', { serverNow: epoch + 4000, entries: [] });
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
expect(get(uiNow)).toBe(epoch);
|
|
expect(get(statusByServer).size).toBe(0);
|
|
expect(frames.size).toBe(1);
|
|
flushFrame();
|
|
expect(get(uiNow)).toBe(epoch + 8000);
|
|
expect(get(statusByServer).size).toBe(1);
|
|
});
|
|
|
|
it('cancels queued frames and discards buffered events when stopping and restarting', async () => {
|
|
const { statusByServer, telemetryByServer, startServerStream } = await setup();
|
|
const oldSource = source;
|
|
const close = vi.spyOn(oldSource, 'close');
|
|
queueEvent('server-status', { entries: [status(epoch)] });
|
|
queueEvent('server-telemetry', {
|
|
entries: [{ ...gameEvent('game_start'), receivedAt: new Date(epoch).toISOString() }]
|
|
});
|
|
const callback = [...frames.values()][0];
|
|
const oldStop = stop!;
|
|
oldStop();
|
|
expect(close).toHaveBeenCalledTimes(1);
|
|
expect(cancelAnimationFrame).toHaveBeenCalledTimes(1);
|
|
expect(frames.size).toBe(0);
|
|
callback(0);
|
|
expect(get(statusByServer).size).toBe(0);
|
|
expect(get(telemetryByServer).size).toBe(0);
|
|
stop = startServerStream();
|
|
oldStop();
|
|
oldSource.dispatchEvent(
|
|
new MessageEvent('server-status', {
|
|
data: JSON.stringify({ entries: [status(epoch)] })
|
|
})
|
|
);
|
|
queueEvent('server-status', { delta: true, entries: [status(epoch, { server: 'new' })] });
|
|
flushFrame();
|
|
expect([...get(statusByServer).keys()]).toEqual(['new']);
|
|
expect(get(telemetryByServer).size).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('Fleet report clock', () => {
|
|
it('accepts newer navigation snapshots while keeping stream evidence newer than the snapshot', async () => {
|
|
const { fleetServers, hydrateFleet } = await setup();
|
|
await vi.advanceTimersByTimeAsync(2000);
|
|
emit(epoch + 2000, [status(epoch + 2000, { players: 3 })]);
|
|
const integrations = { rcon: true, rconTimeoutMs: 3000, defaultRconHost: '' };
|
|
hydrateFleet({
|
|
serverNow: epoch + 1000,
|
|
servers: [server(epoch + 1000), server(epoch, { name: 'two' })],
|
|
operations: [],
|
|
integrations
|
|
});
|
|
expect(get(fleetServers)).toHaveLength(2);
|
|
expect(get(fleetServers)[0].last_players).toBe(3);
|
|
hydrateFleet({ serverNow: epoch, servers: [], operations: [], integrations });
|
|
expect(get(fleetServers)).toHaveLength(2);
|
|
});
|
|
it('merges partial events without dropping other servers or repainting unchanged elapsed ticks', async () => {
|
|
snapshot.servers = [server(), server(epoch, { name: 'two' })];
|
|
const { fleetServers, statusByServer } = await setup();
|
|
emit(epoch, [status(epoch), status(epoch, { server: 'two' })]);
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
source.dispatchEvent(
|
|
new MessageEvent('server-status', {
|
|
data: JSON.stringify({
|
|
delta: true,
|
|
serverNow: epoch + 1000,
|
|
entries: [status(epoch + 1000, { players: 2 })]
|
|
})
|
|
})
|
|
);
|
|
flushFrame();
|
|
expect(get(statusByServer).size).toBe(2);
|
|
expect(
|
|
get(fleetServers).find((server) => server.name === 't5_zombie_theater_1')?.last_players
|
|
).toBe(2);
|
|
const changes = vi.fn();
|
|
const unsubscribe = fleetServers.subscribe(changes);
|
|
changes.mockClear();
|
|
await vi.advanceTimersByTimeAsync(5000);
|
|
expect(changes).not.toHaveBeenCalled();
|
|
unsubscribe();
|
|
});
|
|
it('keeps persisted occupancy and match anchors through live reports before the next status poll', async () => {
|
|
const at = (seconds: number) => new Date(epoch + seconds * 1000).toISOString();
|
|
snapshot.servers = [
|
|
server(epoch, { container_state: 'running', container_checked_at: at(0) })
|
|
];
|
|
const { fleetServers } = await setup();
|
|
const report = (
|
|
event: GameEvent['event'],
|
|
seconds: number,
|
|
players: number,
|
|
timing: ServerTiming
|
|
) => {
|
|
source.dispatchEvent(
|
|
new MessageEvent('server-telemetry', {
|
|
data: JSON.stringify({
|
|
serverNow: epoch + seconds * 1000,
|
|
entries: [
|
|
{
|
|
...gameEvent(event, 2, players),
|
|
receivedAt: at(seconds),
|
|
timing: { matchId: 1, players: {}, server: timing }
|
|
}
|
|
]
|
|
})
|
|
})
|
|
);
|
|
flushFrame();
|
|
return get(fleetServers)[0];
|
|
};
|
|
const active = {
|
|
activeSince: at(1),
|
|
inactiveSince: null,
|
|
gameStartedAt: at(1),
|
|
gameEndedAt: null
|
|
};
|
|
report('game_start', 1, 1, active);
|
|
expect(report('heartbeat', 20, 1, active)).toMatchObject({
|
|
active_since: at(1),
|
|
inactive_since: null,
|
|
game_started_at: at(1),
|
|
game_ended_at: null
|
|
});
|
|
const ended = { ...active, gameEndedAt: at(30) };
|
|
report('game_end', 30, 1, ended);
|
|
expect(report('heartbeat', 31, 1, ended).game_ended_at).toBe(at(30));
|
|
const nextMatch = { ...active, gameStartedAt: at(32) };
|
|
report('game_start', 32, 1, nextMatch);
|
|
expect(report('heartbeat', 33, 1, nextMatch)).toMatchObject({
|
|
active_since: at(1),
|
|
game_started_at: at(32),
|
|
game_ended_at: null
|
|
});
|
|
expect(
|
|
report('heartbeat', 34, 0, {
|
|
activeSince: null,
|
|
inactiveSince: at(34),
|
|
gameStartedAt: null,
|
|
gameEndedAt: null
|
|
})
|
|
).toMatchObject({
|
|
active_since: null,
|
|
inactive_since: at(34),
|
|
game_started_at: null,
|
|
game_ended_at: null
|
|
});
|
|
expect(
|
|
report('heartbeat', 35, 1, {
|
|
activeSince: at(35),
|
|
inactiveSince: null,
|
|
gameStartedAt: null,
|
|
gameEndedAt: null
|
|
})
|
|
).toMatchObject({
|
|
active_since: at(35),
|
|
inactive_since: null,
|
|
game_started_at: null,
|
|
game_ended_at: null
|
|
});
|
|
});
|
|
|
|
it('restores timers from a telemetry snapshot even when the browser missed game_start', async () => {
|
|
const startedAt = new Date(epoch - 19_000).toISOString();
|
|
snapshot.servers = [
|
|
server(epoch, {
|
|
container_state: 'running',
|
|
container_checked_at: new Date(epoch).toISOString()
|
|
})
|
|
];
|
|
const { fleetServers } = await setup();
|
|
source.dispatchEvent(
|
|
new MessageEvent('server-telemetry', {
|
|
data: JSON.stringify({
|
|
serverNow: epoch,
|
|
snapshot: true,
|
|
entries: [
|
|
{
|
|
...gameEvent('heartbeat', 2, 1),
|
|
receivedAt: new Date(epoch).toISOString(),
|
|
timing: {
|
|
matchId: 1,
|
|
players: {},
|
|
server: {
|
|
activeSince: startedAt,
|
|
inactiveSince: null,
|
|
gameStartedAt: startedAt,
|
|
gameEndedAt: null
|
|
}
|
|
}
|
|
}
|
|
]
|
|
})
|
|
})
|
|
);
|
|
flushFrame();
|
|
expect(get(fleetServers)[0]).toMatchObject({
|
|
active_since: startedAt,
|
|
game_started_at: startedAt
|
|
});
|
|
});
|
|
|
|
it('updates count and roster on the next frame and expires them together', async () => {
|
|
snapshot.servers = [
|
|
server(epoch, {
|
|
container_state: 'running',
|
|
container_checked_at: new Date(epoch).toISOString(),
|
|
last_players: 4
|
|
})
|
|
];
|
|
const { fleetServers, uiNow } = await setup();
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
source.dispatchEvent(
|
|
new MessageEvent('server-telemetry', {
|
|
data: JSON.stringify({
|
|
serverNow: epoch + 1000,
|
|
entries: [
|
|
{
|
|
...gameEvent('heartbeat', 15, 3),
|
|
receivedAt: new Date(epoch + 1000).toISOString()
|
|
}
|
|
]
|
|
})
|
|
})
|
|
);
|
|
flushFrame();
|
|
expect(get(fleetServers)[0]).toMatchObject({
|
|
health: 'online',
|
|
last_players: 3,
|
|
status_source: 'telemetry'
|
|
});
|
|
expect(get(fleetServers)[0].roster).toHaveLength(3);
|
|
expect(
|
|
matchesFleet(get(fleetServers)[0], 'Player 3', 'occupied', '', [], [], get(uiNow))
|
|
).toBe(true);
|
|
await vi.advanceTimersByTimeAsync(91_000);
|
|
expect(freshServer(get(fleetServers)[0], get(uiNow))).toBe(false);
|
|
expect(matchesFleet(get(fleetServers)[0], '', 'occupied', '', [], [], get(uiNow))).toBe(
|
|
false
|
|
);
|
|
});
|
|
|
|
it('applies game lifecycle events on the next frame and clears them after an empty report', async () => {
|
|
const { fleetServers } = await setup();
|
|
const report = (event: string, time: number) => {
|
|
source.dispatchEvent(
|
|
new MessageEvent('server-telemetry', {
|
|
data: JSON.stringify({
|
|
serverNow: time,
|
|
entries: [
|
|
{
|
|
server: 't5_zombie_theater_1',
|
|
event,
|
|
receivedAt: new Date(time).toISOString(),
|
|
players: []
|
|
}
|
|
]
|
|
})
|
|
})
|
|
);
|
|
flushFrame();
|
|
};
|
|
emit(epoch, [status(epoch, { players: 1 })]);
|
|
report('game_end', epoch + 1000);
|
|
expect(get(fleetServers)[0].game_ended_at).toBe(new Date(epoch + 1000).toISOString());
|
|
report('heartbeat', epoch + 2000);
|
|
expect(get(fleetServers)[0].game_ended_at).not.toBeNull();
|
|
report('game_start', epoch + 3000);
|
|
expect(get(fleetServers)[0].game_ended_at).toBeNull();
|
|
report('game_end', epoch + 4000);
|
|
await vi.advanceTimersByTimeAsync(5000);
|
|
emit(epoch + 5000, [status(epoch + 5000)]);
|
|
expect(get(fleetServers)[0].game_ended_at).toBeNull();
|
|
emit(epoch + 6000, [status(epoch + 6000, { players: 1 })]);
|
|
expect(get(fleetServers)[0].game_ended_at).toBeNull();
|
|
});
|
|
|
|
it.each([-2500, 60_000])(
|
|
'keeps fresh HTTP and streamed reports healthy with a browser clock offset of %i ms',
|
|
async (offset) => {
|
|
vi.setSystemTime(epoch + offset);
|
|
const { fleetServers, uiNow, refreshFleet } = await setup();
|
|
const expectHealthy = () => {
|
|
const current = get(fleetServers)[0];
|
|
expect(freshServer(current, get(uiNow))).toBe(true);
|
|
expect(matchesFleet(current, '', 'empty', '', [], [], get(uiNow))).toBe(true);
|
|
expect(matchesFleet(current, '', 'attention', '', [], [], get(uiNow))).toBe(false);
|
|
};
|
|
expect(get(uiNow)).toBe(epoch);
|
|
expectHealthy();
|
|
for (let cycle = 1; cycle <= 3; cycle++) {
|
|
for (let second = 0; second < 30; second++) {
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
expectHealthy();
|
|
}
|
|
const now = epoch + cycle * 30_000;
|
|
emit(now, [status(now)]);
|
|
expectHealthy();
|
|
snapshot = { ...snapshot, serverNow: now, servers: [server(now)] };
|
|
await refreshFleet();
|
|
expectHealthy();
|
|
}
|
|
}
|
|
);
|
|
|
|
it('updates the clock before exposing a new status after a suspended UI ticker', async () => {
|
|
const { fleetServers, uiNow } = await setup();
|
|
vi.spyOn(performance, 'now').mockReturnValue(30_000);
|
|
vi.setSystemTime(epoch + 30_000);
|
|
expect(get(uiNow)).toBe(epoch);
|
|
emit(epoch + 30_000, [status(epoch + 30_000)]);
|
|
expect(get(uiNow)).toBe(epoch + 30_000);
|
|
expect(freshServer(get(fleetServers)[0], get(uiNow))).toBe(true);
|
|
});
|
|
|
|
it('does not rewind the clock when an older HTTP snapshot follows a newer stream update', async () => {
|
|
const { fleetServers, uiNow, refreshFleet } = await setup();
|
|
let resolveSnapshot!: (response: Response) => void;
|
|
vi.mocked(fetch).mockImplementationOnce(
|
|
() =>
|
|
new Promise((resolve) => {
|
|
resolveSnapshot = resolve;
|
|
})
|
|
);
|
|
const refreshing = refreshFleet();
|
|
await vi.advanceTimersByTimeAsync(4000);
|
|
emit(epoch + 4000, [status(epoch + 4000)]);
|
|
resolveSnapshot(new Response(JSON.stringify(snapshot)));
|
|
await refreshing;
|
|
expect(get(uiNow)).toBe(epoch + 4000);
|
|
expect(get(fleetServers)[0].last_seen_at).toBe(new Date(epoch + 4000).toISOString());
|
|
expect(freshServer(get(fleetServers)[0], get(uiNow))).toBe(true);
|
|
});
|
|
|
|
it('keeps advancing through wall-clock changes and expires a report if updates stop', async () => {
|
|
const { fleetServers, uiNow } = await setup();
|
|
vi.setSystemTime(epoch - 120_000);
|
|
await vi.advanceTimersByTimeAsync(90_000);
|
|
expect(get(uiNow)).toBe(epoch + 90_000);
|
|
expect(freshServer(get(fleetServers)[0], get(uiNow))).toBe(true);
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
expect(get(uiNow)).toBe(epoch + 91_000);
|
|
expect(freshServer(get(fleetServers)[0], get(uiNow))).toBe(false);
|
|
expect(matchesFleet(get(fleetServers)[0], '', 'attention', '', [], [], get(uiNow))).toBe(
|
|
true
|
|
);
|
|
});
|
|
|
|
it('applies the same RCON failure grace to HTTP snapshots and live updates', async () => {
|
|
const raw = { ...server(), last_error: 'timeout:no reply', rcon_failures: 1 };
|
|
snapshot.servers = [server(epoch, { rcon_status: raw })];
|
|
const { fleetServers, uiNow } = await setup();
|
|
expect(freshServer(get(fleetServers)[0], get(uiNow))).toBe(true);
|
|
expect(get(fleetServers)[0].rcon_status?.last_error).toBe('timeout:no reply');
|
|
await vi.advanceTimersByTimeAsync(30_000);
|
|
emit(epoch + 30_000, [
|
|
status(epoch, {
|
|
health: 'stale',
|
|
lastError: 'timeout:no reply',
|
|
rconStatus: { ...raw, rcon_failures: 2 }
|
|
})
|
|
]);
|
|
expect(get(fleetServers)[0].health).toBe('stale');
|
|
expect(freshServer(get(fleetServers)[0], get(uiNow))).toBe(false);
|
|
await vi.advanceTimersByTimeAsync(30_000);
|
|
emit(epoch + 60_000, [
|
|
status(epoch + 60_000, {
|
|
rconStatus: {
|
|
...raw,
|
|
last_seen_at: new Date(epoch + 60_000).toISOString(),
|
|
last_error: null,
|
|
rcon_failures: 0
|
|
}
|
|
})
|
|
]);
|
|
expect(freshServer(get(fleetServers)[0], get(uiNow))).toBe(true);
|
|
});
|
|
|
|
it('preserves actual RCON failures and stopped-container health', async () => {
|
|
const { fleetServers, uiNow } = await setup();
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
emit(epoch + 1000, [
|
|
status(epoch, { health: 'stale', lastError: 'timeout:no reply', reason: 'timeout' })
|
|
]);
|
|
expect(freshServer(get(fleetServers)[0], get(uiNow))).toBe(false);
|
|
expect(matchesFleet(get(fleetServers)[0], '', 'attention', '', [], [], get(uiNow))).toBe(
|
|
true
|
|
);
|
|
emit(epoch + 1000, [status(epoch, { health: 'offline' })]);
|
|
expect(get(fleetServers)[0].health).toBe('offline');
|
|
expect(freshServer(get(fleetServers)[0], get(uiNow))).toBe(false);
|
|
});
|
|
|
|
it('anchors the initial telemetry snapshot even before Fleet finishes loading', async () => {
|
|
vi.setSystemTime(epoch - 60_000);
|
|
const { startServerStream, uiNow } = await import('../../src/lib/ui/stores/servers.js');
|
|
stop = startServerStream();
|
|
source.dispatchEvent(
|
|
new MessageEvent('server-telemetry', {
|
|
data: JSON.stringify({ serverNow: epoch, entries: [], snapshot: true })
|
|
})
|
|
);
|
|
flushFrame();
|
|
expect(get(uiNow)).toBe(epoch);
|
|
await vi.advanceTimersByTimeAsync(1000);
|
|
expect(get(uiNow)).toBe(epoch + 1000);
|
|
});
|
|
});
|