197 lines
7.0 KiB
TypeScript
197 lines
7.0 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';
|
|
|
|
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('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>');
|
|
});
|