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 { 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(/]*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'); expect(body).toContain('>Players'); expect(body).toContain('>12'); expect(body).toContain('0.0%'); expect(body).toContain('512.0 MiB / 1.0 GiB'); expect(body.match(/>ec-1 { const partial = render(HostList, { props: { hosts: [{ ...host(), serverCount: 3, playerCount: 4, unreportedInstances: 1 }], now } }).body; expect(partial).toContain('>4+'); 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('>—'); const empty = render(HostList, { props: { hosts: [{ ...host(), serverCount: 0, playerCount: 0, unreportedInstances: 0 }], now } }).body; expect(empty).toContain('>0'); });