Separate game file downloads and verification with progress timing
This commit is contained in:
@@ -0,0 +1,236 @@
|
||||
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<string, unknown> = {}) {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { HostLibraryCache } from '../../src/lib/game-files.js';
|
||||
import { gameFileTransferProgress } from '../../src/lib/ui/game-file-transfer.js';
|
||||
|
||||
const now = Date.parse('2026-10-04T16:00:00Z');
|
||||
function cache(changes: Partial<HostLibraryCache> = {}): HostLibraryCache {
|
||||
return {
|
||||
hostId: 'host-1', libraryVersionId: 't5-upload', status: 'downloading', bytesDownloaded: 200, bytesVerified: 0, bytesTotal: 1000,
|
||||
manifestSha256: null, operationId: 'operation-1', error: null, updatedAt: new Date(now).toISOString(),
|
||||
transfer: { phase: 'downloading', startedAt: new Date(now - 10_000).toISOString(), elapsedSeconds: 10,
|
||||
phaseElapsedSeconds: 10, bytesPerSecond: 20, etaSeconds: 40 },
|
||||
...changes
|
||||
};
|
||||
}
|
||||
|
||||
describe('game file transfer presentation', () => {
|
||||
it('ticks elapsed time without inventing downloaded bytes or counting down an old estimate', () => {
|
||||
expect(gameFileTransferProgress(cache(), true, now + 5000)).toMatchObject({
|
||||
phase: 'downloading', phaseBytes: 200, downloaded: 200, total: 1000,
|
||||
elapsedSeconds: 15, bytesPerSecond: 20, etaSeconds: 40, live: true
|
||||
});
|
||||
});
|
||||
|
||||
it('switches progress and the estimate to verification without resetting total elapsed', () => {
|
||||
const verifying = cache({ status: 'verifying', bytesDownloaded: 1000, bytesVerified: 100,
|
||||
transfer: { phase: 'verifying', startedAt: new Date(now - 60_000).toISOString(), elapsedSeconds: 60,
|
||||
phaseElapsedSeconds: 2, bytesPerSecond: 50, etaSeconds: 18 } });
|
||||
expect(gameFileTransferProgress(verifying, true, now + 1000)).toMatchObject({
|
||||
phase: 'verifying', phaseBytes: 100, downloaded: 1000, elapsedSeconds: 61, bytesPerSecond: 50, etaSeconds: 18
|
||||
});
|
||||
});
|
||||
|
||||
it('freezes at the reported duration and hides estimates while offline', () => {
|
||||
expect(gameFileTransferProgress(cache(), false, now + 12_000)).toMatchObject({ elapsedSeconds: 10, etaSeconds: null, bytesPerSecond: null, live: false });
|
||||
});
|
||||
|
||||
it('stops the clock and removes the estimate when reports become stale', () => {
|
||||
expect(gameFileTransferProgress(cache(), true, now + 30_001)).toMatchObject({ elapsedSeconds: 40, etaSeconds: null, bytesPerSecond: null, stale: true });
|
||||
expect(gameFileTransferProgress(cache(), true, now + 600_000).elapsedSeconds).toBe(40);
|
||||
});
|
||||
|
||||
it.each(['ready', 'outdated', 'failed'] as const)('keeps a final duration for %s copies', status => {
|
||||
expect(gameFileTransferProgress(cache({ status }), true, now + 600_000)).toMatchObject({
|
||||
elapsedSeconds: 10, bytesPerSecond: null, etaSeconds: null, active: false, stale: false
|
||||
});
|
||||
});
|
||||
|
||||
it('does not invent a timer for queued downloads or old agents', () => {
|
||||
expect(gameFileTransferProgress(cache({ transfer: undefined }), true, now + 60_000)).toMatchObject({ elapsedSeconds: null, etaSeconds: null, phaseBytes: 200 });
|
||||
expect(gameFileTransferProgress(cache({ status: 'verifying', transfer: undefined, bytesVerified: undefined, bytesDownloaded: 1000 }), true, now))
|
||||
.toMatchObject({ elapsedSeconds: null, etaSeconds: null, phase: 'verifying', phaseBytes: null });
|
||||
});
|
||||
|
||||
it.each([0, null, Number.NaN, Number.POSITIVE_INFINITY, -1])('hides estimates for an unavailable transfer rate (%s)', bytesPerSecond => {
|
||||
const current = cache();
|
||||
current.transfer!.bytesPerSecond = bytesPerSecond;
|
||||
expect(gameFileTransferProgress(current, true, now)).toMatchObject({ etaSeconds: null, bytesPerSecond: null });
|
||||
});
|
||||
|
||||
it('does not show a misleading estimate before any bytes arrive or after all bytes arrive', () => {
|
||||
for (const bytesDownloaded of [0, 1000]) {
|
||||
expect(gameFileTransferProgress(cache({ bytesDownloaded }), true, now)).toMatchObject({ etaSeconds: null, bytesPerSecond: null });
|
||||
}
|
||||
});
|
||||
|
||||
it('does not reuse a download estimate after verification starts', () => {
|
||||
expect(gameFileTransferProgress(cache({ status: 'verifying', bytesDownloaded: 1000, bytesVerified: 50 }), true, now + 5000))
|
||||
.toMatchObject({ phase: 'verifying', phaseBytes: 50, etaSeconds: null, elapsedSeconds: 10 });
|
||||
});
|
||||
|
||||
it('handles an invalid report timestamp without advancing the timer or displaying estimates', () => {
|
||||
expect(gameFileTransferProgress(cache({ updatedAt: 'invalid' }), true, now)).toMatchObject({ elapsedSeconds: 10, etaSeconds: null, stale: true });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user