374 lines
14 KiB
TypeScript
374 lines
14 KiB
TypeScript
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,
|
|
preparedRuntimeImages: [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);
|
|
});
|
|
});
|