Files
electric-cherry/tests/unit/ui-server-health.test.ts

214 lines
7.3 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import type { FleetServer } from '../../src/lib/fleet-overview.js';
import { serverHealth } from '../../src/lib/ui/server-health.js';
const now = Date.parse('2026-10-07T12:00:00Z');
const iso = (offset = 0) => new Date(now + offset).toISOString();
const server: FleetServer = {
name: 't5_zombie_theater_1',
game: 't5',
map: 'zombie_theater',
last_map: 'zombie_theater',
last_gametype: 'zclassic',
last_players: 4,
last_seen_at: iso(-10_000),
last_error: null,
container_state: 'running',
container_checked_at: iso(-5_000),
last_checked_at: iso(-5_000),
roster: [],
health: 'online',
held: [],
active_since: iso(-120_000),
inactive_since: null,
game_started_at: iso(-120_000),
game_ended_at: null,
host_id: 'host-1',
host: {
id: 'host-1',
name: 'Dallas 1',
connected: true,
connectionState: 'online',
lastHeartbeat: iso(-1_000)
}
};
describe('server health presentation', () => {
it('uses current game evidence for Online and expires that evidence with the UI clock', () => {
expect(serverHealth(server, now)).toMatchObject({
state: 'online',
since: server.last_seen_at
});
expect(
serverHealth({ ...server, host: undefined, host_id: undefined }, now + 91_000)
).toMatchObject({
state: 'not-responding',
actionHref: `/servers/${server.name}?tab=diagnostics`,
since: server.last_seen_at
});
});
it('identifies host loss without claiming the game stopped, including stale heartbeat snapshots', () => {
for (const host of [
{ ...server.host!, connected: false, connectionState: 'offline' as const },
{ ...server.host!, lastHeartbeat: iso(-91_000) }
]) {
expect(
serverHealth(
{ ...server, host, health: 'offline', container_state: 'stopped' },
now
)
).toEqual({
state: 'host-offline',
label: 'Host offline',
reason: 'The host agent on Dallas 1 is unavailable. The game may still be running.',
shortReason: 'Dallas 1 unavailable; game may still run.',
actionLabel: 'Check host',
actionHref: '/hosts/host-1',
since: host.lastHeartbeat
});
}
});
it.each(['stopped', 'exited', 'created', 'dead'])(
'only offers Start after a current %s container observation',
(container_state) => {
expect(
serverHealth({ ...server, health: 'offline', container_state }, now)
).toMatchObject({
state: 'stopped',
since: server.container_checked_at,
actionLabel: 'Start server',
actionHref: `/servers/${server.name}?action=start`
});
}
);
it.each([
{ container_checked_at: iso(-91_000), last_checked_at: iso() },
{ container_checked_at: null, last_checked_at: iso() },
{ container_checked_at: 'invalid' },
{ container_checked_at: iso(30_000) },
{ telemetry_after: iso(-2_000) },
{ telemetry_after: 'invalid' },
{ last_seen_at: iso(-1_000) },
{ last_seen_at: iso(-5_000) },
{ host: null }
])(
'does not treat outdated or conflicting stop evidence as a safe Start action: %j',
(change) => {
const result = serverHealth(
{ ...server, health: 'offline', container_state: 'stopped', ...change },
now
);
expect(result.state).toBe('unknown');
expect(result.actionHref).not.toContain('action=start');
}
);
it('separates a running container with failed game checks from a stopped or missing container', () => {
expect(
serverHealth(
{
...server,
health: 'offline',
last_seen_at: iso(-300_000),
last_error: 'timeout:Internal endpoint 10.0.0.1 timed out'
},
now
)
).toEqual({
state: 'not-responding',
label: 'Not responding',
reason: 'The game did not answer the latest status check.',
shortReason: 'No reply to the status check.',
actionLabel: 'Test connection',
actionHref: `/servers/${server.name}?tab=diagnostics`,
since: iso(-300_000)
});
expect(
serverHealth(
{ ...server, health: 'offline', container_state: 'missing', last_seen_at: null },
now
).state
).toBe('unknown');
});
it('uses generic copy for unrecognized failures without exposing internal paths or prefixes', () => {
const result = serverHealth(
{
...server,
health: 'offline',
last_error: 'legacy-failure:/private/server/secret',
container_checked_at: null,
last_seen_at: null
},
now
);
expect(result).toMatchObject({
state: 'unknown',
since: null,
reason: 'There is no current evidence of the game or container state.'
});
expect(result.reason).not.toContain('secret');
});
it('retains explicit startup phases without mistaking old stopped observations for current state', () => {
for (const health of ['starting', 'provisioning'] as const) {
expect(
serverHealth(
{ ...server, health, container_state: 'stopped', telemetry_after: iso(-2_000) },
now
)
).toMatchObject({
state: health,
actionLabel: 'View progress',
since: iso(-2_000)
});
}
});
it('rejects old-process and stale-container evidence for Online', () => {
expect(serverHealth({ ...server, telemetry_after: iso(-2_000) }, now).state).toBe(
'unknown'
);
expect(serverHealth({ ...server, container_checked_at: iso(-91_000) }, now).state).not.toBe(
'online'
);
});
it('uses only valid evidence times and encodes navigation identities', () => {
expect(
serverHealth(
{
...server,
name: 'server / 1',
health: 'offline',
container_checked_at: null,
last_seen_at: iso(30_000),
host: null,
host_id: 'host / 1'
},
now
)
).toMatchObject({
state: 'unknown',
actionHref: '/hosts/host%20%2F%201',
since: null
});
expect(
serverHealth(
{
...server,
name: 'server / 1',
health: 'offline',
container_state: 'unknown',
host: undefined,
host_id: undefined
},
now
).actionHref
).toBe('/servers/server%20%2F%201?tab=diagnostics');
});
});