Add Prettier and ESLint tooling (Refs #77)

This commit is contained in:
2026-10-09 15:56:13 -04:00
parent 8ed059b8d3
commit 16469e2d43
711 changed files with 99737 additions and 28286 deletions
+402 -118
View File
@@ -21,53 +21,104 @@ function flushFrame() {
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
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
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 }) }));
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.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); }));
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(); source = this; }
close() {}
});
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;
stop?.();
stop = undefined;
vi.useRealTimers();
vi.unstubAllGlobals();
vi.restoreAllMocks();
@@ -92,7 +143,12 @@ describe('animation-frame stream batching', () => {
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() })]);
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 })]);
@@ -101,15 +157,31 @@ describe('animation-frame stream batching', () => {
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() })];
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) }] });
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({
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 });
});
@@ -118,49 +190,88 @@ describe('animation-frame stream batching', () => {
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 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());
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() }] });
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());
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));
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');
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() }] });
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()); }
} 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() });
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', {
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', {
snapshot: true,
entries: [telemetry('heartbeat', 'two')]
});
queueEvent('server-telemetry', { entries: [telemetry('game_end', 'three')] });
queueEvent('server-telemetry', { entries: [telemetry('heartbeat', 'three')] });
flushFrame();
@@ -181,11 +292,23 @@ describe('animation-frame stream batching', () => {
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 })] });
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 })] };
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' })] });
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);
@@ -212,7 +335,9 @@ describe('animation-frame stream batching', () => {
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() }] });
queueEvent('server-telemetry', {
entries: [{ ...gameEvent('game_start'), receivedAt: new Date(epoch).toISOString() }]
});
const callback = [...frames.values()][0];
const oldStop = stop!;
oldStop();
@@ -224,7 +349,11 @@ describe('animation-frame stream batching', () => {
expect(get(telemetryByServer).size).toBe(0);
stop = startServerStream();
oldStop();
oldSource.dispatchEvent(new MessageEvent('server-status', { data: JSON.stringify({ entries: [status(epoch)] }) }));
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']);
@@ -238,7 +367,12 @@ describe('Fleet report clock', () => {
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 });
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 });
@@ -249,12 +383,20 @@ describe('Fleet report clock', () => {
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 })]
}) }));
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);
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();
@@ -264,19 +406,45 @@ describe('Fleet report clock', () => {
});
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) })];
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 } }] }) }));
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 };
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
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);
@@ -284,48 +452,136 @@ describe('Fleet report clock', () => {
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
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
});
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() })];
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 } } }] }) }));
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 });
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 })];
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() }] }) }));
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]).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);
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);
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: [] }] })
}));
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 })]);
@@ -343,30 +599,33 @@ describe('Fleet report clock', () => {
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);
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();
}
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();
@@ -381,7 +640,12 @@ describe('Fleet report clock', () => {
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; }));
vi.mocked(fetch).mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveSnapshot = resolve;
})
);
const refreshing = refreshFleet();
await vi.advanceTimersByTimeAsync(4000);
emit(epoch + 4000, [status(epoch + 4000)]);
@@ -401,7 +665,9 @@ describe('Fleet report clock', () => {
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);
expect(matchesFleet(get(fleetServers)[0], '', 'attention', '', [], [], get(uiNow))).toBe(
true
);
});
it('applies the same RCON failure grace to HTTP snapshots and live updates', async () => {
@@ -411,23 +677,39 @@ describe('Fleet report clock', () => {
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 } })]);
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 }
})]);
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' })]);
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);
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);
@@ -437,9 +719,11 @@ describe('Fleet report clock', () => {
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 })
}));
source.dispatchEvent(
new MessageEvent('server-telemetry', {
data: JSON.stringify({ serverNow: epoch, entries: [], snapshot: true })
})
);
flushFrame();
expect(get(uiNow)).toBe(epoch);
await vi.advanceTimersByTimeAsync(1000);