import { createHash } from 'node:crypto'; import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { GameLibraryVersion } from '../../src/lib/game-files.js'; import { newFixture, type Fixture } from '../helpers/fixture.js'; let fixture: Fixture; let hosts: typeof import('../../src/lib/server/hosts/index.js'); let libraries: typeof import('../../src/lib/server/game-files/index.js'); let preparation: typeof import('../../src/lib/server/hosts/preparation.js'); let database: typeof import('../../src/lib/server/db/index.js'); let hostId: string; let version: GameLibraryVersion; const content = 'a'.repeat(100); const startedAt = '2026-10-04T12:00:00.000Z'; const runtime = 'ghcr.io/example/plutainer:transfer-test'; beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); vi.stubEnv('PLUTAINER_IMAGE', runtime); vi.stubEnv('GAME_FILES_ROOT', join(fixture.root, 'game-files')); vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(new Date(startedAt)); hosts = await import('../../src/lib/server/hosts/index.js'); libraries = await import('../../src/lib/server/game-files/index.js'); preparation = await import('../../src/lib/server/hosts/preparation.js'); database = await import('../../src/lib/server/db/index.js'); const host = hosts.createHost({ name: 'Transfer progress host' }); hostId = host.id; hosts.enrollHost(hosts.createEnrollment(hostId).token, { protocolVersion: 1, dockerRunning: true, directoriesWritable: true, storageAvailable: 100 * 1024 ** 3, runtimeImages: [runtime] }); version = await publish(); }); afterEach(async () => { vi.useRealTimers(); vi.unstubAllEnvs(); await fixture.cleanup(); }); async function publish(game: 't5' | 't6' = 't5') { const upload = libraries.createLibraryUpload({ game, files: [{ path: 'zone/game.ff', size: content.length, lastModified: 0 }] }); await libraries.uploadBrowserFile( upload.id, 'zone/game.ff', { offset: 0, size: content.length, sha256: createHash('sha256').update(content).digest('hex') }, new Response(content).body ); return libraries.publishBrowserUpload(upload.id); } function transfer(phase: 'downloading' | 'verifying' = 'downloading') { return { phase, startedAt, elapsedSeconds: 10, phaseElapsedSeconds: phase === 'downloading' ? 10 : 2, bytesPerSecond: 5, etaSeconds: 10 }; } function report(overrides: Record = {}) { return { libraryVersionId: version.id, status: 'downloading', bytesDownloaded: 50, bytesVerified: 0, manifestSha256: version.manifestSha256, transfer: transfer(), ...overrides }; } function cache() { return libraries.getHostLibraryCache(hostId, version.id)!; } describe('host game-file transfer progress', () => { it('persists both phases and exposes them in the library and host preparation views', () => { libraries.queueLibraryDownload(hostId, version.id); libraries.updateHostLibraryCaches(hostId, [report()]); const downloading = cache(); expect(downloading).toMatchObject({ status: 'downloading', bytesDownloaded: 50, bytesVerified: 0, transfer: transfer() }); database.closeDb(); expect(cache()).toEqual(downloading); const verification = transfer('verifying'); libraries.updateHostLibraryCaches(hostId, [ report({ status: 'verifying', bytesDownloaded: 100, bytesVerified: 20, transfer: verification }) ]); const verifying = cache(); expect(verifying).toMatchObject({ status: 'verifying', bytesDownloaded: 100, bytesVerified: 20, transfer: verification }); const overview = libraries .libraryOverview() .libraries.find((library) => library.game === 't5'); expect(overview!.versions.find((item) => item.id === version.id)!.caches).toContainEqual( verifying ); expect(preparation.hostPreparationStatus(hosts.getHost(hostId)!).items).toContainEqual( expect.objectContaining({ kind: 'library', state: 'preparing', cache: verifying }) ); expect(() => libraries.requireReadyLibrary(hostId, version.id)).toThrow( /finish downloading and verifying/ ); }); it('allows an unknown transfer rate and ETA until the agent has measured progress', () => { const initial = { ...transfer(), elapsedSeconds: 0, phaseElapsedSeconds: 0, bytesPerSecond: null, etaSeconds: null }; libraries.updateHostLibraryCaches(hostId, [ report({ bytesDownloaded: 0, transfer: initial }) ]); expect(cache()).toMatchObject({ bytesDownloaded: 0, bytesVerified: 0, transfer: initial }); }); it('retains completed timing when later ready heartbeats omit transfer metadata', () => { const completed = { ...transfer('verifying'), elapsedSeconds: 30, phaseElapsedSeconds: 10, etaSeconds: 0 }; const frozen = { ...completed, bytesPerSecond: null, etaSeconds: null }; libraries.updateHostLibraryCaches(hostId, [ report({ status: 'ready', bytesDownloaded: 100, bytesVerified: 100, transfer: completed }) ]); vi.setSystemTime(Date.now() + 60_000); libraries.updateHostLibraryCaches( hostId, [ { libraryVersionId: version.id, status: 'ready', manifestSha256: version.manifestSha256 } ], { snapshot: true } ); expect(cache()).toMatchObject({ status: 'ready', bytesDownloaded: 100, bytesVerified: 100, transfer: frozen }); expect(libraries.requireReadyLibrary(hostId, version.id).id).toBe(version.id); database.closeDb(); expect(cache().transfer).toEqual(frozen); }); it('clears the previous transfer when the host no longer reports the library', () => { libraries.updateHostLibraryCaches(hostId, [report()]); libraries.updateHostLibraryCaches(hostId, [], { snapshot: true }); expect(cache()).toMatchObject({ status: 'missing', bytesDownloaded: 0, manifestSha256: null }); expect(cache().bytesVerified ?? 0).toBe(0); expect(cache().transfer).toBeUndefined(); }); it('clears old timing and verification when retrying while retaining resumable download bytes', () => { const failed = libraries.queueLibraryDownload(hostId, version.id); libraries.updateHostLibraryCaches(hostId, [ report({ status: 'failed', error: 'Connection reset' }) ]); hosts.reportHostOperation(hostId, failed.id, { state: 'failed', error: 'Connection reset' }); const retry = libraries.queueLibraryDownload(hostId, version.id); expect(retry.id).not.toBe(failed.id); expect(cache()).toMatchObject({ status: 'downloading', bytesDownloaded: 50, operationId: retry.id }); expect(cache().bytesVerified ?? 0).toBe(0); expect(cache().transfer).toBeUndefined(); }); it('continues accepting older agents without phase metadata', () => { libraries.updateHostLibraryCaches(hostId, [ { libraryVersionId: version.id, status: 'downloading', bytesDownloaded: 25, manifestSha256: version.manifestSha256 } ]); expect(cache()).toMatchObject({ status: 'downloading', bytesDownloaded: 25 }); expect(cache().transfer).toBeUndefined(); // Earlier agents verified files as they downloaded, so a verifying report // could still contain a partial aggregate download count. libraries.updateHostLibraryCaches(hostId, [ { libraryVersionId: version.id, status: 'verifying', bytesDownloaded: 25, manifestSha256: version.manifestSha256 } ]); expect(cache().status).toBe('verifying'); libraries.updateHostLibraryCaches(hostId, [ { libraryVersionId: version.id, status: 'ready', manifestSha256: version.manifestSha256 } ]); expect(cache()).toMatchObject({ status: 'ready', bytesDownloaded: 100 }); expect(libraries.requireReadyLibrary(hostId, version.id).id).toBe(version.id); }); it('does not leave an old ETA attached to a new report from an older agent', () => { libraries.updateHostLibraryCaches(hostId, [report()]); libraries.updateHostLibraryCaches(hostId, [ { libraryVersionId: version.id, status: 'downloading', bytesDownloaded: 60, manifestSha256: version.manifestSha256 } ]); expect(cache()).toMatchObject({ status: 'downloading', bytesDownloaded: 60 }); expect(cache().transfer).toBeUndefined(); }); it.each([ ['negative verified bytes', { bytesVerified: -1 }], ['verified bytes exceed the library', { bytesVerified: 101 }], ['fractional verified bytes', { bytesVerified: 0.5 }], ['nonfinite verified bytes', { bytesVerified: Number.POSITIVE_INFINITY }], ['downloaded bytes exceed the library', { bytesDownloaded: 101 }], [ 'a partial download in the verification phase', { status: 'verifying', transfer: transfer('verifying') } ], [ 'ready before verification finishes', { status: 'ready', bytesDownloaded: 100, bytesVerified: 20, transfer: transfer('verifying') } ], ['a phase inconsistent with status', { transfer: transfer('verifying') }], ['an unknown phase', { transfer: { ...transfer(), phase: 'uploading' } }], ['a malformed start time', { transfer: { ...transfer(), startedAt: 'not-a-timestamp' } }], ['a non-string start time', { transfer: { ...transfer(), startedAt: 1234 } }], ['a negative elapsed time', { transfer: { ...transfer(), elapsedSeconds: -1 } }], ['a nonfinite elapsed time', { transfer: { ...transfer(), elapsedSeconds: Number.NaN } }], ['a negative phase elapsed time', { transfer: { ...transfer(), phaseElapsedSeconds: -1 } }], [ 'a nonfinite phase elapsed time', { transfer: { ...transfer(), phaseElapsedSeconds: Number.POSITIVE_INFINITY } } ], [ 'a phase longer than the full transfer', { transfer: { ...transfer(), phaseElapsedSeconds: 11 } } ], ['a negative transfer rate', { transfer: { ...transfer(), bytesPerSecond: -1 } }], [ 'a nonfinite transfer rate', { transfer: { ...transfer(), bytesPerSecond: Number.POSITIVE_INFINITY } } ], ['a negative remaining time', { transfer: { ...transfer(), etaSeconds: -1 } }], [ 'a nonfinite remaining time', { transfer: { ...transfer(), etaSeconds: Number.POSITIVE_INFINITY } } ] ])('rejects %s without changing the existing cache', (_name, invalid) => { libraries.updateHostLibraryCaches(hostId, [report()]); const before = cache(); vi.setSystemTime(Date.now() + 1_000); expect(() => libraries.updateHostLibraryCaches(hostId, [report(invalid)])).toThrow(); expect(cache()).toEqual(before); }); it('rolls back the entire heartbeat when a later cache contains invalid metadata', async () => { const second = await publish('t6'); libraries.updateHostLibraryCaches(hostId, [ report(), report({ libraryVersionId: second.id }) ]); const beforeFirst = cache(); const beforeSecond = libraries.getHostLibraryCache(hostId, second.id); expect(() => libraries.updateHostLibraryCaches( hostId, [ report({ bytesDownloaded: 70 }), report({ libraryVersionId: second.id, transfer: { ...transfer(), elapsedSeconds: -1 } }) ], { snapshot: true } ) ).toThrow(); expect(cache()).toEqual(beforeFirst); expect(libraries.getHostLibraryCache(hostId, second.id)).toEqual(beforeSecond); }); it('adds transfer columns to an existing database without losing cached files or operation references', () => { const operation = libraries.queueLibraryDownload(hostId, version.id); libraries.updateHostLibraryCaches(hostId, [ { libraryVersionId: version.id, status: 'ready', manifestSha256: version.manifestSha256 } ]); const before = cache(); database.db().exec('ALTER TABLE host_library_caches DROP COLUMN transfer_progress'); database.db().exec('ALTER TABLE host_library_caches DROP COLUMN bytes_verified'); database.closeDb(); expect(cache()).toMatchObject({ hostId, libraryVersionId: version.id, status: 'ready', bytesDownloaded: 100, bytesTotal: 100, manifestSha256: version.manifestSha256, operationId: operation.id, updatedAt: before.updatedAt }); expect(cache().transfer).toBeUndefined(); expect(libraries.requireReadyLibrary(hostId, version.id).id).toBe(version.id); database.closeDb(); expect(cache().operationId).toBe(operation.id); }); });