Simplify game files to automatic T5 and T6 uploads
This commit is contained in:
@@ -9,7 +9,7 @@ const state = vi.hoisted(() => ({ handle: null as Database.Database | null }));
|
||||
vi.mock('../../src/lib/server/db/index.js', () => ({ db: () => state.handle }));
|
||||
import {
|
||||
browserUploadStatus, createLibraryImport, createLibraryUpload, getHostLibraryCache,
|
||||
getLibraryVersion, listLibraries, publishBrowserUpload, queueLibraryDownload,
|
||||
getLibraryVersion, latestPublishedLibraries, listLibraries, newestReadyLibrary, publishBrowserUpload, queueLibraryDownload,
|
||||
requestBrowserPublication, requireReadyLibrary, updateHostLibraryCaches, uploadBrowserFile
|
||||
} from '../../src/lib/server/game-files/index.js';
|
||||
import { createEnrollment, createHost, enrollHost, listHosts } from '../../src/lib/server/hosts/index.js';
|
||||
@@ -24,8 +24,7 @@ const sha = (value: Uint8Array) => createHash('sha256').update(value).digest('he
|
||||
const file = (path = 'zone/base.ff', content = bytes) => ({ path, size: content.length, lastModified: modified });
|
||||
const upload = (id: string, path: string, content: Buffer, offset = 0, checksum = sha(content)) =>
|
||||
uploadBrowserFile(id, path, { offset, size: content.length, sha256: checksum }, new Response(new Uint8Array(content)).body);
|
||||
const begin = (game: 't5' | 't6' = 't6', files = [file()], version = '1') =>
|
||||
createLibraryUpload({ game, name: `Plutonium ${game.toUpperCase()}`, version, description: 'Uploaded game folder', files });
|
||||
const begin = (game: 't5' | 't6' = 't6', files = [file()]) => createLibraryUpload({ game, files });
|
||||
|
||||
beforeEach(async () => {
|
||||
directory = await mkdtemp(join(tmpdir(), 'ec-browser-library-'));
|
||||
@@ -37,6 +36,7 @@ afterEach(async () => {
|
||||
state.handle?.close(); state.handle = null;
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
vi.unstubAllEnvs();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function host() {
|
||||
@@ -65,7 +65,7 @@ describe('browser folder uploads', () => {
|
||||
expect(ready.manifestSha256).toBe(manifestChecksum(ready.files));
|
||||
expect(await readFile(join(ready.storageLocation, file().path))).toEqual(bytes);
|
||||
expect(await browserUploadStatus(initial.id)).toMatchObject({ status: 'published' });
|
||||
expect(listLibraries()[0].versions[0]).not.toHaveProperty('dlc');
|
||||
expect(listLibraries().find(library => library.game === game)?.versions[0]).not.toHaveProperty('dlc');
|
||||
expect(listHosts()).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -122,7 +122,7 @@ describe('browser folder uploads', () => {
|
||||
const chunkSize = (await browserUploadStatus(sizeProbe.id)).chunkSize;
|
||||
expect(chunkSize).toBeGreaterThan(0);
|
||||
const large = Buffer.alloc(chunkSize + 17, 0x41);
|
||||
const initial = begin('t6', [file('main/archive.iwd', large)], 'large');
|
||||
const initial = begin('t6', [file('main/archive.iwd', large)]);
|
||||
const first = large.subarray(0, chunkSize);
|
||||
await upload(initial.id, 'main/archive.iwd', first);
|
||||
expect((await browserUploadStatus(initial.id)).files[0]).toMatchObject({ receivedBytes: chunkSize, complete: false });
|
||||
@@ -146,17 +146,54 @@ describe('browser folder uploads', () => {
|
||||
expect((await browserUploadStatus(initial.id)).files[0].receivedBytes).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps ready bytes and version metadata immutable and requires a new version label', async () => {
|
||||
it('keeps published files immutable while accepting repeated uploads without labels', async () => {
|
||||
const initial = begin(); await upload(initial.id, file().path, bytes);
|
||||
const ready = await publishBrowserUpload(initial.id);
|
||||
await expect(upload(initial.id, file().path, bytes)).rejects.toThrow();
|
||||
expect(() => state.handle!.prepare('UPDATE game_file_versions SET description=? WHERE id=?').run('changed', ready.id)).toThrow(/immutable/);
|
||||
expect(() => begin()).toThrow(/already exists/);
|
||||
expect((await publishBrowserUpload(initial.id)).manifestSha256).toBe(ready.manifestSha256);
|
||||
expect(begin('t6', [file()], '2').id).not.toBe(ready.id);
|
||||
const next = begin();
|
||||
expect(next.id).not.toBe(ready.id);
|
||||
expect(next.version).not.toBe(ready.version);
|
||||
expect(next.libraryId).toBe(ready.libraryId);
|
||||
expect(await readFile(join(ready.storageLocation, file().path))).toEqual(bytes);
|
||||
});
|
||||
|
||||
it('ignores legacy names, descriptions, and labels when accepting new game files', () => {
|
||||
const legacyFields = { name: 'same name', version: 'same version', description: 'a library description' };
|
||||
const first = createLibraryUpload({ game: 't5', files: [file()], ...legacyFields });
|
||||
const next = createLibraryUpload({ game: 't5', files: [file()], ...legacyFields });
|
||||
const otherGame = createLibraryUpload({ game: 't6', files: [file()], ...legacyFields });
|
||||
expect(first.description).toBe('');
|
||||
expect(first.version).not.toBe(legacyFields.version);
|
||||
expect(next.version).not.toBe(first.version);
|
||||
expect(next.libraryId).toBe(first.libraryId);
|
||||
expect(otherGame.libraryId).not.toBe(first.libraryId);
|
||||
expect(listLibraries().map(library => library.name)).toEqual(['Black Ops I (T5)', 'Black Ops II (T6)']);
|
||||
});
|
||||
|
||||
it('follows completion order and preserves the latest successful files during failed or unfinished uploads', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
|
||||
const slow = begin();
|
||||
vi.setSystemTime(new Date('2026-01-02T00:00:00Z'));
|
||||
const fast = begin();
|
||||
await upload(fast.id, file().path, bytes);
|
||||
vi.setSystemTime(new Date('2026-01-03T00:00:00Z'));
|
||||
await publishBrowserUpload(fast.id);
|
||||
expect(newestReadyLibrary('t6')?.id).toBe(fast.id);
|
||||
await upload(slow.id, file().path, bytes);
|
||||
vi.setSystemTime(new Date('2026-01-04T00:00:00Z'));
|
||||
await publishBrowserUpload(slow.id);
|
||||
const failed = begin();
|
||||
await requestBrowserPublication(failed.id);
|
||||
await vi.waitFor(() => expect(getLibraryVersion(failed.id)?.state).toBe('failed'));
|
||||
begin();
|
||||
expect(newestReadyLibrary('t6')?.id).toBe(slow.id);
|
||||
expect(latestPublishedLibraries().map(version => version.id)).toEqual([slow.id]);
|
||||
expect(listLibraries().find(library => library.game === 't6')?.latestVersionId).toBe(slow.id);
|
||||
});
|
||||
|
||||
it('recovers publication after the verified directory was renamed but the ready commit was interrupted', async () => {
|
||||
const initial = begin(); await upload(initial.id, file().path, bytes);
|
||||
const files = [{ path: file().path, size: bytes.length, sha256: sha(bytes) }];
|
||||
@@ -220,7 +257,7 @@ describe('browser folder uploads', () => {
|
||||
expect(getHostLibraryCache(target.id, ready.id)?.status).toBe('failed');
|
||||
updateHostLibraryCaches(target.id, [{ libraryVersionId: ready.id, status: 'ready', manifestSha256: ready.manifestSha256 }]);
|
||||
expect(requireReadyLibrary(target.id, ready.id).id).toBe(ready.id);
|
||||
const replacement = begin('t5', [file()], '2'); await upload(replacement.id, file().path, bytes);
|
||||
const replacement = begin('t5'); await upload(replacement.id, file().path, bytes);
|
||||
await publishBrowserUpload(replacement.id);
|
||||
expect(requireReadyLibrary(target.id, ready.id).manifestSha256).toBe(ready.manifestSha256);
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
const state = vi.hoisted(() => ({ handle: null as Database.Database | null }));
|
||||
vi.mock('../../src/lib/server/db/index.js', () => ({ db: () => state.handle }));
|
||||
import { createEnrollment, createHost, enrollHost, getHostOperation, reportHostOperation, revokeCredentials } from '../../src/lib/server/hosts/index.js';
|
||||
import { createLibraryImport, getHostLibraryCache, getLibraryVersion, importPublicationStatus, listLibraries, publishImport, queueLibraryDownload, requestImportPublication, requireReadyLibrary, setImportManifest, updateHostLibraryCaches, uploadImportFile } from '../../src/lib/server/game-files/index.js';
|
||||
import { createLibraryImport, getHostLibraryCache, getLibraryVersion, importPublicationStatus, latestPublishedLibraries, libraryOverview, listLibraries, newestReadyLibrary, publishImport, queueLibraryDownload, requestImportPublication, requireReadyLibrary, setImportManifest, updateHostLibraryCaches, uploadImportFile } from '../../src/lib/server/game-files/index.js';
|
||||
import { serveGameFile } from '../../src/lib/server/game-files/http.js';
|
||||
import { GET as manifestRoute } from '../../src/routes/api/agent/libraries/[id]/manifest/+server.js';
|
||||
|
||||
@@ -22,12 +22,87 @@ beforeEach(async () => {
|
||||
credential = enrollHost(enrollment.token, { protocolVersion: 1, dockerRunning: true, directoriesWritable: true, storageAvailable: 1_000_000_000_000 }).credential;
|
||||
});
|
||||
afterEach(async () => { state.handle?.close(); await rm(directory, { recursive: true, force: true }); vi.unstubAllEnvs(); });
|
||||
function begin(version = '1') { return createLibraryImport({ hostId, game: 't6', name: 'Black Ops II', version, description: 'Complete files', dlc: ['Origins'] }); }
|
||||
function begin() { return createLibraryImport({ hostId, game: 't6' }); }
|
||||
async function published() {
|
||||
const version = begin(); setImportManifest(hostId, version.id, [file]);
|
||||
await uploadImportFile(hostId, version.id, file.path, new Response(data).body);
|
||||
return publishImport(hostId, version.id);
|
||||
}
|
||||
|
||||
function legacyLibrary(id: string, game: 't5' | 't6') {
|
||||
state.handle!.prepare('INSERT INTO game_file_libraries(id,name,game,description,created_at) VALUES(?,?,?,?,?)')
|
||||
.run(id, `User named ${id}`, game, 'Original description', '2025-01-01T00:00:00.000Z');
|
||||
}
|
||||
function legacyVersion(id: string, libraryId: string, createdAt: string, publishedAt: string) {
|
||||
state.handle!.prepare(`INSERT INTO game_file_versions(id,library_id,version,description,dlc,state,created_at,published_at,storage_location,source_host_id,source_volume,manifest_sha256)
|
||||
VALUES(?,?,?,'Legacy version','[]','ready',?,?,?,'','','checksum')`)
|
||||
.run(id, libraryId, `User label ${id}`, createdAt, publishedAt, join(directory, id));
|
||||
}
|
||||
|
||||
describe('fixed game file slots', () => {
|
||||
it('always lists exactly T5 and T6, including before any files are uploaded', () => {
|
||||
expect(listLibraries()).toEqual([
|
||||
{ id: 't5', game: 't5', name: 'Black Ops I (T5)', description: '', createdAt: '', latestVersionId: null, versions: [] },
|
||||
{ id: 't6', game: 't6', name: 'Black Ops II (T6)', description: '', createdAt: '', latestVersionId: null, versions: [] }
|
||||
]);
|
||||
expect(latestPublishedLibraries()).toEqual([]);
|
||||
expect(newestReadyLibrary('t5')).toBeNull();
|
||||
expect(newestReadyLibrary('t6')).toBeNull();
|
||||
});
|
||||
|
||||
it('unifies old named libraries by game without rewriting published versions or their host references', () => {
|
||||
listLibraries();
|
||||
legacyLibrary('first-t6-library', 't6');
|
||||
legacyLibrary('other-t6-library', 't6');
|
||||
legacyLibrary('t5-library', 't5');
|
||||
legacyVersion('old-t6', 'first-t6-library', '2025-01-03', '2025-01-04');
|
||||
legacyVersion('new-t6', 'other-t6-library', '2025-01-01', '2025-01-05');
|
||||
legacyVersion('t5-files', 't5-library', '2025-01-01', '2025-01-02');
|
||||
updateHostLibraryCaches(hostId, [
|
||||
{ libraryVersionId: 'old-t6', status: 'ready', manifestSha256: 'checksum' },
|
||||
{ libraryVersionId: 'new-t6', status: 'ready', manifestSha256: 'checksum' }
|
||||
]);
|
||||
const before = getLibraryVersion('old-t6');
|
||||
const slots = listLibraries();
|
||||
expect(slots.map(library => library.id)).toEqual(['t5', 't6']);
|
||||
expect(slots[1].versions.map(version => version.id)).toEqual(['old-t6', 'new-t6']);
|
||||
expect(slots.map(library => library.latestVersionId)).toEqual(['t5-files', 'new-t6']);
|
||||
expect(latestPublishedLibraries().map(version => version.id)).toEqual(['t5-files', 'new-t6']);
|
||||
expect(newestReadyLibrary('t6')?.id).toBe('new-t6');
|
||||
const overview = libraryOverview().libraries[1];
|
||||
expect(overview.versions.find(version => version.id === 'old-t6')?.caches[0].status).toBe('outdated');
|
||||
expect(overview.versions.find(version => version.id === 'new-t6')?.caches[0].status).toBe('ready');
|
||||
expect(getLibraryVersion('old-t6')).toEqual(before);
|
||||
expect(requireReadyLibrary(hostId, 'old-t6')).toMatchObject({
|
||||
libraryId: 'first-t6-library', version: 'User label old-t6', storageLocation: join(directory, 'old-t6')
|
||||
});
|
||||
expect(begin().libraryId).toBe('first-t6-library');
|
||||
expect(listLibraries()[1].latestVersionId).toBe('new-t6');
|
||||
});
|
||||
|
||||
it('breaks publication-time ties by creation time and then insertion order across old libraries', () => {
|
||||
listLibraries();
|
||||
legacyLibrary('first-library', 't6');
|
||||
legacyLibrary('second-library', 't6');
|
||||
legacyVersion('first', 'first-library', '2025-01-01', '2025-01-05');
|
||||
legacyVersion('second', 'second-library', '2025-01-02', '2025-01-05');
|
||||
legacyVersion('third', 'first-library', '2025-01-02', '2025-01-05');
|
||||
legacyVersion('later-created', 'second-library', '2025-01-04', '2025-01-04');
|
||||
expect(newestReadyLibrary('t6')?.id).toBe('third');
|
||||
expect(latestPublishedLibraries().map(version => version.id)).toEqual(['third']);
|
||||
expect(listLibraries()[1].latestVersionId).toBe('third');
|
||||
});
|
||||
|
||||
it('ignores user-supplied library metadata when importing from hosts', () => {
|
||||
const first = createLibraryImport({ hostId, game: 't6', name: 'shared', version: '1', description: 'Old label' });
|
||||
const next = createLibraryImport({ hostId, game: 't6', name: 'shared', version: '1', description: 'Old label' });
|
||||
expect(first.description).toBe('');
|
||||
expect(first.version).not.toBe('1');
|
||||
expect(next.version).not.toBe(first.version);
|
||||
expect(first.libraryId).toBe(next.libraryId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('game-file import lifecycle', () => {
|
||||
it('publishes verified filesystem content and permanently pins manifest metadata', async () => {
|
||||
const initial = begin();
|
||||
@@ -42,8 +117,10 @@ describe('game-file import lifecycle', () => {
|
||||
expect((await publishImport(hostId, ready.id)).id).toBe(ready.id);
|
||||
await expect(uploadImportFile(hostId, ready.id, file.path, new Response(data).body)).rejects.toThrow(/not writable/);
|
||||
expect(() => state.handle!.prepare('UPDATE game_file_versions SET description=? WHERE id=?').run('changed', ready.id)).toThrow(/immutable/);
|
||||
expect(() => begin()).toThrow(/already exists/);
|
||||
expect(begin('2').id).not.toBe(ready.id);
|
||||
const next = begin();
|
||||
expect(next.id).not.toBe(ready.id);
|
||||
expect(next.version).not.toBe(ready.version);
|
||||
expect(next.libraryId).toBe(ready.libraryId);
|
||||
});
|
||||
it('restricts an import to its source host and rejects manifest mutation', () => {
|
||||
const version = begin();
|
||||
@@ -55,7 +132,7 @@ describe('game-file import lifecycle', () => {
|
||||
it('surfaces durable agent failures while preserving historical versions', () => {
|
||||
const version = begin();
|
||||
reportHostOperation(hostId, version.operationId!, { state: 'failed', error: 'Source volume not found' });
|
||||
expect(listLibraries()[0].versions[0]).toMatchObject({ state: 'failed', error: 'Source volume not found' });
|
||||
expect(listLibraries().find(library => library.game === 't6')?.versions[0]).toMatchObject({ state: 'failed', error: 'Source volume not found' });
|
||||
});
|
||||
it('returns a pollable publication status while large files are verified asynchronously', async () => {
|
||||
const version = begin(); setImportManifest(hostId, version.id, [file]);
|
||||
|
||||
@@ -50,10 +50,10 @@ function operations(id: string) {
|
||||
return hosts.listHostOperations(id).filter(operation => ['runtime.pull', 'library.download'].includes(operation.kind));
|
||||
}
|
||||
|
||||
async function publish(name = 'T5 files', version = '1', game: 't5' | 't6' = 't5', bytes = content) {
|
||||
async function publish(game: 't5' | 't6' = 't5', bytes = content) {
|
||||
vi.setSystemTime(Date.now() + 1);
|
||||
const upload = libraries.createLibraryUpload({
|
||||
name, version, game, files: [{ path: 'zone/game.ff', size: bytes.length, lastModified: 0 }]
|
||||
game, files: [{ path: 'zone/game.ff', size: bytes.length, lastModified: 0 }]
|
||||
});
|
||||
await libraries.uploadBrowserFile(upload.id, 'zone/game.ff', {
|
||||
offset: 0, size: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex')
|
||||
@@ -177,17 +177,16 @@ describe('automatic host preparation', () => {
|
||||
expect(operations(id)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('downloads the newest published version of every library, including separate libraries for the same game', async () => {
|
||||
const old = await publish('T5 files', 'old');
|
||||
const latest = await publish('T5 files', 'new');
|
||||
const alternate = await publish('T5 alternate');
|
||||
const t6 = await publish('T6 files', '1', 't6');
|
||||
it('always targets the latest verified T5 and T6 upload while newer uploads remain unfinished', async () => {
|
||||
const old = await publish();
|
||||
const latest = await publish();
|
||||
const t6 = await publish('t6');
|
||||
libraries.createLibraryUpload({
|
||||
name: 'T5 files', version: 'unfinished', game: 't5',
|
||||
game: 't5',
|
||||
files: [{ path: 'zone/game.ff', size: content.length, lastModified: 0 }]
|
||||
});
|
||||
const id = enroll({ runtimeImages: [runtime] });
|
||||
const expected = new Map([latest, alternate, t6].map(version => [version.id, version]));
|
||||
const expected = new Map([latest, t6].map(version => [version.id, version]));
|
||||
const downloaded: string[] = [];
|
||||
for (let index = 0; index < expected.size; index++) {
|
||||
preparation.reconcileHostPreparation(id);
|
||||
@@ -218,7 +217,7 @@ describe('automatic host preparation', () => {
|
||||
verified(id, old);
|
||||
heartbeat(id, { runtimeImages: [runtime], libraries: [{ id: old.id, state: 'ready' }] });
|
||||
expect(preparation.reconcileHostPreparation(id).state).toBe('ready');
|
||||
const latest = await publish('T5 files', '2');
|
||||
const latest = await publish();
|
||||
preparation.reconcileHostPreparation(id);
|
||||
expect(operations(id)).toHaveLength(1);
|
||||
expect(operations(id)[0].payload.libraryVersionId).toBe(latest.id);
|
||||
@@ -226,6 +225,41 @@ describe('automatic host preparation', () => {
|
||||
expect(libraries.getHostLibraryCache(id, old.id)?.status).toBe('ready');
|
||||
});
|
||||
|
||||
it('replaces an undelivered automatic download when newer files finish uploading', async () => {
|
||||
const old = await publish();
|
||||
const id = enroll({ runtimeImages: [runtime] });
|
||||
preparation.reconcileHostPreparation(id);
|
||||
const first = operations(id)[0];
|
||||
expect(first.payload).toMatchObject({ libraryVersionId: old.id, automatic: true });
|
||||
// The next heartbeat may discover a completed cache while this queued
|
||||
// retry has still never been delivered. Keep that verified evidence.
|
||||
verified(id, old);
|
||||
const latest = await publish();
|
||||
preparation.reconcileHostPreparation(id);
|
||||
expect(hosts.getHostOperation(first.id)).toMatchObject({ state: 'failed', error: expect.stringContaining('Superseded') });
|
||||
expect(operations(id).filter(operation => operation.state === 'pending')).toMatchObject([
|
||||
{ payload: { libraryVersionId: latest.id, automatic: true } }
|
||||
]);
|
||||
expect(libraries.requireReadyLibrary(id, old.id).id).toBe(old.id);
|
||||
});
|
||||
|
||||
it('finishes a delivered download before following a newer upload under a new operation ID', async () => {
|
||||
const old = await publish();
|
||||
const id = enroll({ runtimeImages: [runtime] });
|
||||
preparation.reconcileHostPreparation(id);
|
||||
const [first] = hosts.pendingHostOperations(id);
|
||||
const latest = await publish();
|
||||
preparation.reconcileHostPreparation(id);
|
||||
expect(operations(id)).toHaveLength(1);
|
||||
expect(hosts.getHostOperation(first.id)?.state).toBe('pending');
|
||||
verified(id, old);
|
||||
hosts.reportHostOperation(id, first.id, { state: 'completed' });
|
||||
heartbeat(id, { runtimeImages: [runtime], libraries: [{ id: old.id, state: 'ready' }] });
|
||||
preparation.reconcileHostPreparation(id);
|
||||
expect(operations(id)[0]).toMatchObject({ state: 'pending', payload: { libraryVersionId: latest.id } });
|
||||
expect(libraries.requireReadyLibrary(id, old.id).id).toBe(old.id);
|
||||
});
|
||||
|
||||
it.each(['draining', 'disabled'] as const)('does not start setup for a %s host', mode => {
|
||||
const id = enroll();
|
||||
hosts.updateHost(id, { mode });
|
||||
@@ -305,15 +339,15 @@ describe('automatic host preparation', () => {
|
||||
expect(operations(id)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('lets other libraries prepare when an earlier library cannot fit', async () => {
|
||||
const oversized = await publish('A oversized');
|
||||
const small = await publish('B small', '1', 't6', 'small');
|
||||
it('prepares T6 when the latest T5 upload cannot fit', async () => {
|
||||
const oversized = await publish();
|
||||
const small = await publish('t6', 'small');
|
||||
const id = enroll({ runtimeImages: [runtime], storageAvailable: 1_010 });
|
||||
hosts.updateHost(id, { minimumFreeBytes: 1_000 });
|
||||
const status = preparation.reconcileHostPreparation(id);
|
||||
expect(status.items).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({ label: 'A oversized · 1', state: 'blocked' }),
|
||||
expect.objectContaining({ label: 'B small · 1', state: 'preparing' })
|
||||
expect.objectContaining({ label: 'Black Ops I (T5)', state: 'blocked' }),
|
||||
expect.objectContaining({ label: 'Black Ops II (T6)', state: 'preparing' })
|
||||
]));
|
||||
expect(operations(id)).toHaveLength(1);
|
||||
expect(operations(id)[0].payload.libraryVersionId).toBe(small.id);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { GameLibrary, GameLibraryGame, GameLibraryVersion } from '../../src/lib/game-files.js';
|
||||
import { latestGameFiles, selectLatestGameFiles } from '../../src/lib/ui/native-game-files.js';
|
||||
|
||||
function upload(id: string, game: GameLibraryGame = 't5', state: GameLibraryVersion['state'] = 'ready'): GameLibraryVersion {
|
||||
return {
|
||||
id, libraryId: game, game, version: id, description: '', state,
|
||||
sizeBytes: 10, fileCount: 1, manifestSha256: 'manifest', files: [],
|
||||
createdAt: '2026-10-04T12:00:00Z', publishedAt: state === 'ready' ? '2026-10-04T12:05:00Z' : null,
|
||||
storageLocation: '', sourceHostId: null, sourceVolume: null, error: null, operationId: null, caches: []
|
||||
};
|
||||
}
|
||||
|
||||
function slot(game: GameLibraryGame, latestVersionId: string | null, versions: GameLibraryVersion[]): GameLibrary {
|
||||
return { id: game, name: game.toUpperCase(), game, description: '', createdAt: '', latestVersionId, versions };
|
||||
}
|
||||
|
||||
describe('automatic game file selection for new launches', () => {
|
||||
it('follows the controller latest upload even when older files are already cached', () => {
|
||||
const old = upload('old');
|
||||
old.caches = [{ hostId: 'host', libraryVersionId: old.id, status: 'ready', bytesDownloaded: 10,
|
||||
bytesTotal: 10, manifestSha256: 'manifest', error: null, updatedAt: '', operationId: null }];
|
||||
const current = upload('latest');
|
||||
// The API can retain older uploads and unfinished uploads in its history.
|
||||
const libraries = [slot('t5', current.id, [upload('unfinished', 't5', 'importing'), old, current])];
|
||||
const selected = { t5: old.id };
|
||||
const latest = latestGameFiles(libraries);
|
||||
|
||||
expect(latest.t5).toBe(current);
|
||||
expect(selectLatestGameFiles(selected, latest, ['t5'])).toBe(true);
|
||||
expect(selected.t5).toBe(current.id);
|
||||
expect(selectLatestGameFiles(selected, latest, ['t5'])).toBe(false);
|
||||
});
|
||||
|
||||
it('updates both games and removes a stale selection when a game has no available upload', () => {
|
||||
const latest = latestGameFiles([slot('t5', null, [upload('unavailable')]), slot('t6', 'new-t6', [upload('new-t6', 't6')])]);
|
||||
const selected: Partial<Record<GameLibraryGame, string>> = { t5: 'old-t5', t6: 'old-t6' };
|
||||
|
||||
expect(selectLatestGameFiles(selected, latest, ['t5', 't6'])).toBe(true);
|
||||
expect(selected).toEqual({ t6: 'new-t6' });
|
||||
});
|
||||
|
||||
it('never selects an unfinished or missing latest upload from history', () => {
|
||||
expect(latestGameFiles([slot('t5', 'pending', [upload('old'), upload('pending', 't5', 'importing')])])).toEqual({});
|
||||
expect(latestGameFiles([slot('t6', 'missing', [upload('old', 't6')])])).toEqual({});
|
||||
});
|
||||
|
||||
it('only changes games needed by the current launch', () => {
|
||||
const selected = { t5: 'old-t5', t6: 'keep-t6' };
|
||||
const latest = latestGameFiles([slot('t5', 'new-t5', [upload('new-t5')])]);
|
||||
|
||||
expect(selectLatestGameFiles(selected, latest, ['t5'])).toBe(true);
|
||||
expect(selected).toEqual({ t5: 'new-t5', t6: 'keep-t6' });
|
||||
});
|
||||
});
|
||||
@@ -33,7 +33,7 @@ afterEach(async () => { await fixture.cleanup(); vi.unstubAllEnvs(); });
|
||||
|
||||
async function prepareLibrary(game: 't5' | 't6' = 't5') {
|
||||
const libs = await import('../../src/lib/server/game-files/index.js');
|
||||
const version = libs.createLibraryImport({ hostId, game, name: game, version: '1' });
|
||||
const version = libs.createLibraryImport({ hostId, game });
|
||||
const bytes = Buffer.from('verified base game');
|
||||
const file = { path: 'zone/base.ff', size: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') };
|
||||
libs.setImportManifest(hostId, version.id, [file]);
|
||||
@@ -73,6 +73,39 @@ async function detail(id: string) {
|
||||
}
|
||||
|
||||
describe('native instance provisioning', () => {
|
||||
it('selects the latest game files automatically and rejects stale explicit selections', async () => {
|
||||
const { nativeSelection } = await import('../../src/lib/server/provisioning/native.js');
|
||||
expect(() => nativeSelection({ hostId, image }, 't5')).toThrow('Upload T5 game files');
|
||||
const old = await prepareLibrary('t5');
|
||||
expect(nativeSelection({ hostId, image }, 't5').libraryId).toBe(old.id);
|
||||
const latest = await prepareLibrary('t5');
|
||||
expect(nativeSelection({ hostId, image }, 't5').libraryId).toBe(latest.id);
|
||||
expect(() => nativeSelection({ hostId, image, libraryId: old.id }, 't5')).toThrow('Refresh the launch preview');
|
||||
});
|
||||
|
||||
it('rejects a launch preview made before newer game files were published without reserving a server', async () => {
|
||||
await prepareLibrary('t5');
|
||||
const { previewNativeCreation, queueNativeCreation } = await import('../../src/lib/server/provisioning/native.js');
|
||||
const preview = previewNativeCreation({ hostId, image, game: 't5', mapSlug: 'theater' });
|
||||
await prepareLibrary('t5');
|
||||
const result = queueNativeCreation({ game: 't5', gameMode: 'zombie', mapSlug: 'theater', previewId: preview.id, plutoKey: 'new-key' });
|
||||
expect(result).toMatchObject({ ok: false, message: expect.stringContaining('Refresh the launch preview') });
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
expect(db().prepare('SELECT COUNT(*) AS count FROM native_instances').get()).toEqual({ count: 0 });
|
||||
expect(db().prepare('SELECT COUNT(*) AS count FROM ec_host_ports').get()).toEqual({ count: 0 });
|
||||
});
|
||||
|
||||
it('keeps a queued server pinned to its accepted files when newer game files arrive', async () => {
|
||||
const job = await queue();
|
||||
const acceptedId = libraryId;
|
||||
const latest = await prepareLibrary('t5');
|
||||
expect(latest.id).not.toBe(acceptedId);
|
||||
await advance();
|
||||
expect((await operationFor(job.deploymentId))?.payload.libraryId).toBe(acceptedId);
|
||||
const { nativeInstance } = await import('../../src/lib/server/backends/native.js');
|
||||
expect(nativeInstance(job.name)?.library_id).toBe(acceptedId);
|
||||
});
|
||||
|
||||
it.each(['t5', 't6'] as const)('queues and verifies a %s deployment without Komodo', async game => {
|
||||
const job = await queue(game); await advance();
|
||||
const operation = await operationFor(job.deploymentId);
|
||||
|
||||
Reference in New Issue
Block a user