Files
electric-cherry/tests/unit/ui-host-overview.test.ts
T

232 lines
8.3 KiB
TypeScript

import { expect, it } from 'vitest';
import { render } from 'svelte/server';
import type { Host } from '../../src/lib/hosts.js';
import {
hostListStatus,
hostReadingsStale,
hostCpuUsage,
hostUsedSpace,
stableHostOrder
} from '../../src/lib/ui/host-overview.js';
import HostList from '../../src/lib/ui/components/HostList.svelte';
import HostTemperatures from '../../src/lib/ui/components/HostTemperatures.svelte';
const now = Date.parse('2026-10-06T12:00:00Z');
function host(id = 'ec-1', changes: Partial<Host> = {}): Host {
return {
id,
name: id,
hostname: id,
mode: 'active',
portMin: 28960,
portMax: 28980,
bindAddress: '0.0.0.0',
publicAddress: '',
minimumFreeBytes: 100,
createdAt: new Date(now).toISOString(),
lastHeartbeat: new Date(now).toISOString(),
connected: true,
connectionState: 'online',
readiness: 'ready',
readinessReasons: [],
inventory: {
cpuUsagePercent: 0,
memoryTotal: 1024 ** 3,
memoryAvailable: 512 * 1024 ** 2,
storageTotal: 10 * 1024 ** 3,
storageAvailable: 8 * 1024 ** 3
},
enrolled: true,
credentialActive: true,
decommission: null,
preparation: { state: 'ready', items: [] },
...changes
};
}
it('shows CPU temperature with stale status and keeps sensor details expandable', () => {
const target = host('ec-2', {
connected: false,
inventory: {
cpuUsagePercent: 25,
temperatures: [
{
id: 'cpu',
kind: 'cpu-package',
device: 'coretemp',
label: 'Package id 0',
celsius: 65
}
]
}
});
const list = render(HostList, {
props: {
hosts: [{ ...target, serverCount: 0, playerCount: 0, unreportedInstances: 0 }],
now
}
}).body;
expect(list).toContain('CPU temp: 65 °C');
expect(list).toContain('Resource readings stale');
const detail = render(HostTemperatures, { props: { host: target } }).body;
expect(detail).toContain('(last reported)');
expect(detail).toContain('Temperature sensors');
expect(detail).toContain('CPU package 0');
expect(detail).not.toMatch(/<details[^>]*open/);
const missing = render(HostTemperatures, { props: { host: host() } }).body;
expect(missing).toContain('Unavailable');
expect(missing).not.toContain('0 °C');
});
it('does not show an old Ready state as available when the heartbeat becomes stale', () => {
expect(hostListStatus(host(), now).label).toBe('Ready');
expect(hostReadingsStale(host(), now + 90_001)).toBe(true);
expect(hostListStatus(host(), now + 90_001)).toMatchObject({ key: 'offline', priority: 0 });
expect(hostReadingsStale(host('bad-date', { lastHeartbeat: 'invalid' }), now)).toBe(true);
expect(
hostListStatus(host('new', { enrolled: false, connected: false, lastHeartbeat: null }), now)
.label
).toBe('Awaiting enrollment');
});
it('keeps deliberate disablement and deletion distinct from unexpected outages', () => {
expect(
hostListStatus(host('disabled', { mode: 'disabled', connected: false }), now).label
).toBe('Disabled');
expect(hostListStatus(host('draining', { mode: 'draining' }), now).label).toBe('Draining');
const deleting = host('deleting', {
connected: false,
decommission: {
operationId: 'delete',
stage: 'cleaned',
requestedAt: '',
updatedAt: '',
error: null,
retryable: false
}
});
expect(hostListStatus(deleting, now).label).toBe('Deleting');
deleting.decommission!.stage = 'failed';
deleting.decommission!.error = 'Cleanup did not finish';
expect(hostListStatus(deleting, now)).toMatchObject({
key: 'attention',
label: 'Cleanup failed',
detail: 'Cleanup did not finish'
});
});
it('surfaces setup and update failures without treating ordinary preparation as failure', () => {
const preparing = host('preparing', {
preparation: {
state: 'preparing',
items: [
{
kind: 'runtime',
label: 'Runtime',
state: 'preparing',
detail: 'Downloading runtime'
}
]
}
});
expect(hostListStatus(preparing, now)).toMatchObject({
key: 'preparing',
detail: 'Downloading runtime'
});
preparing.preparation!.state = 'blocked';
preparing.preparation!.items[0].state = 'blocked';
expect(hostListStatus(preparing, now).key).toBe('attention');
expect(
hostListStatus(
host('update', {
inventory: {
agentUpdate: {
enabled: true,
state: 'failed',
currentVersion: '1.0',
error: 'Invalid signature'
}
}
}),
now
)
).toMatchObject({ key: 'attention', detail: 'Agent update failed: Invalid signature' });
expect(hostListStatus(host('revoked', { credentialActive: false }), now)).toMatchObject({
key: 'attention',
tab: 'settings'
});
});
it('sorts issues first initially and preserves existing positions when polls change status', () => {
const healthy = host('a');
const offline = host('z', { connected: false });
const order = stableHostOrder([], [healthy, offline], now);
expect(order).toEqual(['z', 'a']);
expect(
stableHostOrder(order, [host('z'), host('a', { connected: false }), host('new')], now)
).toEqual(['z', 'a', 'new']);
expect(stableHostOrder(order, [healthy], now)).toEqual(['a']);
});
it('distinguishes real zero usage from missing or invalid metrics', () => {
expect(hostCpuUsage(0)).toBe('0.0%');
for (const value of [undefined, null, NaN, Infinity, -1, 101])
expect(hostCpuUsage(value)).toBe('Not reported');
expect(hostUsedSpace(1024, 1024)).toBe('0 B / 1.0 KiB');
expect(hostUsedSpace(1024, undefined)).toBe('Not reported');
expect(hostUsedSpace(1024, 2048)).toBe('Not reported');
});
it('renders compact host links and workload without duplicate identity, setup metadata or deletion actions', () => {
const body = render(HostList, {
props: {
hosts: [{ ...host(), serverCount: 3, playerCount: 12, unreportedInstances: 0 }],
now
}
}).body;
expect(body).toContain('3 assigned servers on ec-1');
expect(body).toContain('>Servers</th>');
expect(body).toContain('>Players</th>');
expect(body).toContain('>12</td>');
expect(body).toContain('0.0%');
expect(body).toContain('512.0 MiB / 1.0 GiB');
expect(body.match(/>ec-1</g)).toHaveLength(1);
expect(body).not.toContain('Delete host');
expect(body).not.toContain('Game ports');
expect(body).not.toContain('Automatic setup');
expect(body).not.toContain('Resource readings stale');
const stale = render(HostList, {
props: {
hosts: [{ ...host(), serverCount: 3, playerCount: 12, unreportedInstances: 0 }],
now: now + 90_001
}
}).body;
expect(stale).toContain('Resource readings stale');
expect(stale).toContain('Offline');
});
it('marks partial player totals and distinguishes unavailable counts from empty hosts', () => {
const partial = render(HostList, {
props: {
hosts: [{ ...host(), serverCount: 3, playerCount: 4, unreportedInstances: 1 }],
now
}
}).body;
expect(partial).toContain('>4+</td>');
expect(partial).toContain('Player counts unavailable for 1 of 3 servers');
const unknown = render(HostList, {
props: {
hosts: [{ ...host(), serverCount: 3, playerCount: 0, unreportedInstances: 3 }],
now
}
}).body;
expect(unknown).toContain('>—</td>');
const empty = render(HostList, {
props: {
hosts: [{ ...host(), serverCount: 0, playerCount: 0, unreportedInstances: 0 }],
now
}
}).body;
expect(empty).toContain('>0</td>');
});