import { setImmediate } from 'node:timers/promises'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { newFixture, type Fixture } from '../helpers/fixture.js'; import type { CapacityData, CapacityQuery } from '../../src/lib/capacity.js'; let fixture: Fixture; let hostId: string; const GiB = 1024 ** 3; const epoch = Date.parse('2026-10-05T12:00:00Z'); const image = 'capacity-test-runtime'; const query: CapacityQuery = { range: '1h', hostId: null, game: 'all' }; const inventory = { protocolVersion: 1, dockerRunning: true, directoriesWritable: true, storageAvailable: 100 * GiB, cpuCount: 16, cpuUsagePercent: 0, memoryTotal: 64 * GiB, memoryAvailable: 60 * GiB, runtimeImages: [image], libraries: [ { id: 't5-version', state: 'ready' }, { id: 't6-version', state: 'ready' } ] }; async function settle() { await setImmediate(); await setImmediate(); } async function addHost(name: string, overrides = {}) { const hosts = await import('../../src/lib/server/hosts/index.js'); const host = hosts.createHost({ name, portMin: 28960, portMax: 28962 }); hosts.enrollHost(hosts.createEnrollment(host.id).token, { ...inventory, ...overrides }); hosts.heartbeat(host.id, { ...inventory, ...overrides }); await settle(); return host.id; } beforeEach(async () => { vi.resetModules(); vi.stubEnv('PLUTAINER_IMAGE', image); fixture = await newFixture([]); vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(epoch); const { db } = await import('../../src/lib/server/db/index.js'); const { ensureGameFileSchema } = await import('../../src/lib/server/game-files/schema.js'); ensureGameFileSchema(db()); for (const game of ['t5', 't6']) { db() .prepare( 'INSERT INTO game_file_libraries(id,name,game,description,created_at) VALUES(?,?,?,?,?)' ) .run(`${game}-library`, game, game, '', new Date(epoch).toISOString()); db() .prepare( `INSERT INTO game_file_versions (id,library_id,version,description,dlc,state,created_at,published_at,storage_location,source_host_id,source_volume) VALUES(?,?,?,'','[]','ready',?,?,'','','')` ) .run( `${game}-version`, `${game}-library`, '1', new Date(epoch).toISOString(), new Date(epoch).toISOString() ); } hostId = await addHost('Main host'); }); afterEach(async () => { vi.restoreAllMocks(); vi.unstubAllEnvs(); vi.useRealTimers(); await fixture.cleanup(); }); describe('capacity overview', () => { it('shares the cached launch estimate and counts a dual-game host only once without placement or history reads', async () => { const { readCapacityCurrent } = await import('../../src/lib/server/hosts/capacity-overview.js'); const { launchAvailability } = await import('../../src/lib/server/hosts/availability.js'); const placement = await import('../../src/lib/server/hosts/capacity.js'); const { db } = await import('../../src/lib/server/db/index.js'); const choose = vi.spyOn(placement, 'choosePlacement'); const prepare = vi.spyOn(db(), 'prepare'); const current = await readCapacityCurrent(query); const launch = launchAvailability( Array.from({ length: 20 }, (_, index) => ({ image, libraryId: index % 2 ? 't5-version' : 't6-version', automatic: true })) ).availability; expect(current.summary).toMatchObject({ estimatedServers: 3, eligibleHosts: 1, totalHosts: 1, portsAvailable: 3, cpuCoresAvailable: launch.cpuCoresAvailable, memoryBytesAvailable: launch.memoryBytesAvailable }); expect(current.summary.estimatedServers).toBe(launch.estimatedAvailable); expect(current.hosts[0]).toMatchObject({ games: ['t5', 't6'], limitingResource: 'Ports', calibration: { running: { source: 'bootstrap' }, startup: { source: 'bootstrap' } } }); expect(choose).not.toHaveBeenCalled(); expect( prepare.mock.calls.some(([sql]) => /ec_host_resource_samples|ec_instance_resource_samples|native_instances/.test(sql) ) ).toBe(false); }); it('filters launch eligibility by game and host while keeping all hosts available in the selector', async () => { const t5Only = await addHost('T5 only', { libraries: [{ id: 't5-version', state: 'ready' }] }); const { readCapacityCurrent } = await import('../../src/lib/server/hosts/capacity-overview.js'); expect((await readCapacityCurrent(query)).summary).toMatchObject({ estimatedServers: 6, eligibleHosts: 2 }); const t6 = await readCapacityCurrent({ ...query, game: 't6' }); expect(t6.summary).toMatchObject({ estimatedServers: 3, eligibleHosts: 1, totalHosts: 2 }); expect(t6.hosts.find((host) => host.id === t5Only)?.reasons.join(' ')).toContain( 'Latest game files' ); const selected = await readCapacityCurrent({ ...query, game: 't5', hostId: t5Only }); expect(selected.summary).toMatchObject({ estimatedServers: 3, eligibleHosts: 1, totalHosts: 1 }); expect(selected.hosts.map((host) => host.id)).toEqual([t5Only]); expect(selected.hostOptions.map((host) => host.id)).toEqual([hostId, t5Only]); }); it('excludes stale and disabled hosts and does not show stale resource readings as current', async () => { const other = await addHost('Disabled host'); const { updateHost, heartbeat } = await import('../../src/lib/server/hosts/index.js'); updateHost(other, { mode: 'disabled' }); await settle(); const { readCapacityCurrent } = await import('../../src/lib/server/hosts/capacity-overview.js'); expect((await readCapacityCurrent(query)).summary.eligibleHosts).toBe(1); vi.setSystemTime(epoch + 91_000); heartbeat(other, inventory); const current = await readCapacityCurrent(query); expect(current.summary).toMatchObject({ estimatedServers: 0, cpuCoresAvailable: 0, memoryBytesAvailable: 0, portsAvailable: 0, eligibleHosts: 0, totalHosts: 2 }); expect(current.hosts.find((host) => host.id === hostId)).toMatchObject({ connected: false, eligible: false, cpuCoresAvailable: null, memoryBytesAvailable: null }); expect(current.hosts.find((host) => host.id === other)?.reasons.join(' ')).toContain( 'disabled' ); }); it('preserves unknown measurements and distinguishes them from measured exhaustion', async () => { const { heartbeat } = await import('../../src/lib/server/hosts/index.js'); const { readCapacityCurrent } = await import('../../src/lib/server/hosts/capacity-overview.js'); heartbeat(hostId, { ...inventory, cpuUsagePercent: undefined, memoryAvailable: 65 * GiB }); const unknown = await readCapacityCurrent(query); expect(unknown.summary).toMatchObject({ estimatedServers: null, cpuCoresAvailable: null, memoryBytesAvailable: null, eligibleHosts: 1, portsAvailable: 3 }); expect(unknown.hosts[0].limitingResource).toBeNull(); expect(unknown.warnings.join(' ')).toContain('unknown'); heartbeat(hostId, { ...inventory, cpuUsagePercent: 100 }); expect((await readCapacityCurrent(query)).hosts[0]).toMatchObject({ estimatedServers: 0, cpuCoresAvailable: 0, limitingResource: 'CPU' }); }); it.each([ ['CPU', { cpuUsagePercent: 90 }, 1], ['Memory', { memoryAvailable: GiB + 500_000_000 }, 1], ['Ports', {}, 3] ])( 'identifies %s as the resource limiting additional servers', async (limitingResource, overrides, estimatedServers) => { const { heartbeat } = await import('../../src/lib/server/hosts/index.js'); heartbeat(hostId, { ...inventory, ...overrides }); const { readCapacityCurrent } = await import('../../src/lib/server/hosts/capacity-overview.js'); expect((await readCapacityCurrent(query)).hosts[0]).toMatchObject({ limitingResource, estimatedServers }); } ); it('reserves OS headroom and unmeasured startups without double-counting TCP and UDP ports', async () => { const { db } = await import('../../src/lib/server/db/index.js'); const { beginDeployment } = await import('../../src/lib/server/provisioning/history.js'); const { heartbeat, reserveHostPort } = await import('../../src/lib/server/hosts/index.js'); const deployment = beginDeployment('t5_zombie_theater_1', 'create'); db() .prepare( `INSERT INTO native_instances(id,server,host_id,library_id,image,port,created_at) VALUES('starting','t5_zombie_theater_1',?,'t5-version',?,28960,?)` ) .run(hostId, image, new Date(epoch).toISOString()); db() .prepare( 'INSERT INTO native_provisioning(deployment_id,instance_id,generation) VALUES(?,?,1)' ) .run(deployment, 'starting'); reserveHostPort(hostId, 'starting', { port: 28960, protocol: 'udp' }); reserveHostPort(hostId, 'starting', { port: 28960, protocol: 'tcp' }); heartbeat(hostId, inventory); const { readCapacityCurrent } = await import('../../src/lib/server/hosts/capacity-overview.js'); const current = await readCapacityCurrent(query); expect(current.summary).toMatchObject({ cpuCoresAvailable: 14.7, memoryBytesAvailable: 59 * GiB - 500_000_000, portsAvailable: 2, activeStartups: 1 }); heartbeat(hostId, { ...inventory, instanceResources: [ { instanceId: 'starting', generation: 1, uptimeSeconds: 30, cpuCores: 0.1, memoryBytes: GiB / 4 } ] }); expect((await readCapacityCurrent(query)).summary).toMatchObject({ cpuCoresAvailable: 15.2, memoryBytesAvailable: 60 * GiB - 500_000_000, portsAvailable: 2 }); }); it('serves non-cacheable filtered history and current data and rejects unknown hosts', async () => { const { GET } = await import('../../src/routes/api/capacity/+server.js'); const response = await GET({ url: new URL(`https://ec.test/api/capacity?range=1h&game=t6&host=${hostId}`) }); expect(response.headers.get('cache-control')).toBe('no-store'); const result = (await response.json()) as CapacityData; expect(result.query).toEqual({ ...query, game: 't6', hostId }); expect(result.current.hosts.map((host) => host.id)).toEqual([hostId]); expect(result.history).toMatchObject({ range: '1h', bucketMs: 60_000 }); expect(result.history.points).toHaveLength(60); const fallback = await GET({ url: new URL('https://ec.test/api/capacity?range=unbounded&game=invalid') }); expect((await fallback.json()).query).toEqual({ range: '24h', game: 'all', hostId: null }); await expect( GET({ url: new URL('https://ec.test/api/capacity?host=missing') }) ).rejects.toMatchObject({ status: 404 }); }); });