Support multi-game betas with automatic server selection

This commit is contained in:
2026-10-07 09:41:17 -04:00
parent a6dee8c5e2
commit c6352d86b4
17 changed files with 881 additions and 173 deletions
+81
View File
@@ -0,0 +1,81 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
beforeEach(async () => {
vi.resetModules();
fixture = await newFixture([]);
});
afterEach(async () => { await fixture.cleanup(); vi.restoreAllMocks(); });
async function seedBeta(multiple: boolean) {
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
const { createScript, getLatestVersion } = await import('../../src/lib/server/fleet/scripts.js');
const games = multiple ? ['t5', 't6'] : ['t5'];
const scripts = games.map(game => {
const { script } = createScript({ name: `${game} test`, targetRelPath: `${game}.gsc`,
scopeType: 'game', scopeValue: game, content: Buffer.from(`${game} version`) });
return { ...script, versionId: getLatestVersion(script.id)!.id };
});
const betaId = Number(db().prepare(`INSERT INTO betas (script_id, script_version_id, trigger_kind, state)
VALUES (?, ?, 'bundle', 'running')`).run(scripts[0].id, scripts[0].versionId).lastInsertRowid);
for (const script of scripts) db().prepare('INSERT INTO beta_scripts (beta_id, script_id, script_version_id) VALUES (?, ?, ?)')
.run(betaId, script.id, script.versionId);
for (const [name, game] of [['first', 't5'], ['second', multiple ? 't6' : 't5']]) {
db().prepare('INSERT INTO servers (name, game) VALUES (?, ?)').run(name, game);
db().prepare(`INSERT INTO beta_servers (beta_id, server, had_original, prior_version_id, stage)
VALUES (?, ?, 1, ?, 'awaiting-empty')`).run(betaId, name, scripts[0].versionId);
}
const assign = (server: string, scriptId: number) => db().prepare(`INSERT INTO beta_server_scripts
(beta_id, server, script_id, had_original) VALUES (?, ?, ?, 0)`).run(betaId, server, scriptId);
const memberships = () => db().prepare(`SELECT server, script_id, had_original, prior_version_id
FROM beta_server_scripts WHERE beta_id = ? ORDER BY server, script_id`).all(betaId);
return { betaId, scripts, assign, memberships, db, closeDb };
}
describe('per-server beta membership', () => {
it('shows only each game’s held files in fleet and server snapshots', async () => {
const x = await seedBeta(true);
x.assign('first', x.scripts[0].id);
x.assign('second', x.scripts[1].id);
const { fleetSnapshot } = await import('../../src/lib/server/fleet/snapshot.js');
const expected = [
{ name: 'first', held: [{ target: 't5.gsc', betaId: x.betaId }] },
{ name: 'second', held: [{ target: 't6.gsc', betaId: x.betaId }] }
];
expect(fleetSnapshot().servers.map(({ name, held }) => ({ name, held }))).toEqual(expected);
expect(fleetSnapshot('second').servers.map(({ name, held }) => ({ name, held }))).toEqual([expected[1]]);
});
it('preserves game-specific membership when the database reopens', async () => {
const x = await seedBeta(true);
x.assign('first', x.scripts[0].id);
x.assign('second', x.scripts[1].id);
const expected = x.memberships();
x.closeDb();
expect(x.memberships()).toEqual(expected);
x.closeDb();
expect(x.memberships()).toEqual(expected);
});
it('does not infer missing bundle membership from the primary script', async () => {
const x = await seedBeta(true);
x.assign('first', x.scripts[0].id);
const expected = x.memberships();
x.closeDb();
expect(x.memberships()).toEqual(expected);
});
it('still backfills legacy single-script servers while retaining existing snapshots', async () => {
const x = await seedBeta(false);
x.assign('first', x.scripts[0].id);
x.db().prepare('DELETE FROM beta_scripts WHERE beta_id = ?').run(x.betaId);
x.closeDb();
expect(x.memberships()).toEqual([
{ server: 'first', script_id: x.scripts[0].id, had_original: 0, prior_version_id: null },
{ server: 'second', script_id: x.scripts[0].id, had_original: 1, prior_version_id: x.scripts[0].versionId }
]);
expect(x.db().prepare('SELECT script_id, script_version_id FROM beta_scripts WHERE beta_id = ?').all(x.betaId))
.toEqual([{ script_id: x.scripts[0].id, script_version_id: x.scripts[0].versionId }]);
});
});
+159
View File
@@ -0,0 +1,159 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { existsSync } from 'node:fs';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { newFixture, type Fixture } from '../helpers/fixture.js';
import { activateFiles } from '../helpers/staged-files.js';
vi.mock('../../src/lib/server/pipeline/worker.js', () => ({ kick: vi.fn() }));
let fixture: Fixture;
const t5 = ['t5_zombie_theater_1', 't5_zombie_theater_2'];
const t6 = ['t6_zombie_town_1', 't6_zombie_town_2'];
const participants = [t5[0], t6[0]];
beforeEach(async () => {
vi.resetModules();
fixture = await newFixture([...t5, ...t6]);
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
await seedServerRegistry();
});
afterEach(async () => { await fixture.cleanup(); });
async function setup() {
const library = await import('../../src/lib/server/fleet/scripts.js');
const beta = await import('../../src/lib/server/fleet/beta.js');
const { db } = await import('../../src/lib/server/db/index.js');
const { stagedFileChanges } = await import('../../src/lib/server/fleet/staged-files.js');
const { destOfKind, configFileOf } = await import('../../src/lib/server/config.js');
const create = (game: string, path = 'shared.gsc') => library.createScript({ name: `${game} ${path}`, targetRelPath: path,
scopeType: 'game', scopeValue: game, content: Buffer.from(`${game} tested`) });
const open = (files: ReturnType<typeof create>[], servers = participants) => beta.openBeta({
scripts: files.map(file => ({ scriptId: file.script.id, scriptVersionId: file.version.id })),
servers, waitForHealth: false, waitForMatch: false
});
const ready = (id: number) => {
db().prepare("UPDATE betas SET state = 'ready-promote' WHERE id = ?").run(id);
db().prepare("UPDATE beta_servers SET stage = 'ready', restarted_at = ? WHERE beta_id = ?").run(new Date().toISOString(), id);
};
return { ...library, ...beta, db: db(), stagedFileChanges, destOfKind, configFileOf, create, open, ready };
}
describe('multi-game beta bundles', () => {
it('stages, revises and rolls back only each server\'s matching files', async () => {
const x = await setup();
const files = [x.create('t5', 'five.gsc'), x.create('t6', 'six.gsc')];
// The other game's filename can already exist independently on a server.
const unrelated = join(x.destOfKind(t5[0], 'script'), 'six.gsc');
await writeFile(unrelated, 'unrelated original');
const { betaId } = await x.open(files);
expect(x.stagedFileChanges('beta', String(betaId)).map(row => [row.server, row.rel_path]))
.toEqual([[t5[0], 'five.gsc'], [t6[0], 'six.gsc']]);
const { heldByBeta, ownerOf } = await import('../../src/lib/server/fleet/servers.js');
expect([...heldByBeta(t5[0]).keys()]).toEqual(['five.gsc']);
expect(ownerOf(t5[0], 'six.gsc')).toBeNull();
await activateFiles('beta', betaId);
await x.reviseBeta(betaId, 1, [{ scriptId: files[1].script.id, content: Buffer.from('t6 revised') }]);
expect(x.stagedFileChanges('beta', String(betaId)).map(row => [row.server, row.rel_path]))
.toEqual([[t5[0], 'five.gsc'], [t6[0], 'six.gsc']]);
await activateFiles('beta', betaId);
expect(await readFile(join(x.destOfKind(t6[0], 'script'), 'six.gsc'), 'utf8')).toBe('t6 revised');
const rollback = await x.rollbackBeta(betaId);
expect(x.stagedFileChanges('campaign', rollback.deploymentId!).map(row => [row.server, row.rel_path]))
.toEqual([[t5[0], 'five.gsc'], [t6[0], 'six.gsc']]);
await activateFiles('campaign', rollback.deploymentId!);
expect(await readFile(unrelated, 'utf8')).toBe('unrelated original');
expect(existsSync(join(x.destOfKind(t5[0], 'script'), 'five.gsc'))).toBe(false);
expect(existsSync(join(x.destOfKind(t6[0], 'script'), 'six.gsc'))).toBe(false);
});
it('promotes same-named files to their own game and restores the correct inventory on rollback', async () => {
const x = await setup();
const { recordScript, getServerScript } = await import('../../src/lib/server/fleet/inventory.js');
const files = [x.create('t5'), x.create('t6')];
const priors = files.map(file => x.uploadNewVersion(file.script.id, Buffer.from(`${file.script.scope_value} original`)));
for (let i = 0; i < files.length; i++) {
x.db.prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(priors[i].id, files[i].script.id);
for (const server of i === 0 ? t5 : t6) {
await writeFile(join(x.destOfKind(server, 'script'), 'shared.gsc'), `${files[i].script.scope_value} original`);
recordScript(server, 'shared.gsc', priors[i].id, priors[i].sha256, priors[i].size, 'test');
}
}
const { betaId } = await x.open(files);
await activateFiles('beta', betaId); x.ready(betaId);
const preview = x.previewBetaPromotion(betaId);
expect(preview.files.map(file => file.servers)).toEqual([t5, t6]);
expect(preview.blockers).toEqual([]);
const promotion = await x.promoteBeta(betaId, preview.token);
expect(promotion.awaitingActivation).toBe(2);
await activateFiles('campaign', promotion.deploymentId);
for (const server of [...t5, ...t6]) {
expect(await readFile(join(x.destOfKind(server, 'script'), 'shared.gsc'), 'utf8')).toBe(`${server.slice(0, 2)} tested`);
}
const { rollbackPromoteDeployment } = await import('../../src/lib/server/fleet/rollback.js');
const rollback = await rollbackPromoteDeployment(promotion.deploymentId);
await activateFiles('campaign', rollback.deploymentId);
for (const server of [...t5, ...t6]) {
expect(await readFile(join(x.destOfKind(server, 'script'), 'shared.gsc'), 'utf8')).toBe(`${server.slice(0, 2)} original`);
expect(getServerScript(server, 'shared.gsc')?.script_version_id).toBe(priors[server.startsWith('t5') ? 0 : 1].id);
}
});
it('composes and promotes separate baselines and overlays for both games', async () => {
const x = await setup();
const files = ['t5', 't6'].flatMap(game => [
x.createScript({ name: `${game} baseline`, kind: 'baseline', scopeType: 'game', scopeValue: game,
content: Buffer.from(`set baseline_game "${game}"\nset overlay_game "none"\n`) }),
x.createScript({ name: `${game} overlay`, kind: 'config', scopeType: 'game', scopeValue: game,
content: Buffer.from(`set overlay_game "${game}"\n`) })
]);
const { betaId } = await x.open(files);
await activateFiles('beta', betaId); x.ready(betaId);
const promotion = await x.promoteBeta(betaId);
await activateFiles('campaign', promotion.deploymentId);
for (const server of [...t5, ...t6]) {
const content = await readFile(join(x.destOfKind(server, 'config'), x.configFileOf(server)), 'utf8');
expect(content).toContain(`set baseline_game "${server.slice(0, 2)}"`);
expect(content).toContain(`set overlay_game "${server.slice(0, 2)}"`);
expect(content).not.toContain(`"${server.startsWith('t5') ? 't6' : 't5'}"`);
}
});
it('leaves the configuration on script-only participants untouched during rollback', async () => {
const x = await setup();
const baseline = x.createScript({ name: 'T5 config', kind: 'baseline', scopeType: 'game', scopeValue: 't5', content: Buffer.from('set sv_voice "1"\n') });
const config = join(x.destOfKind(t6[0], 'config'), x.configFileOf(t6[0]));
await mkdir(dirname(config), { recursive: true }); await writeFile(config, 'unrelated T6 config');
const { betaId } = await x.open([baseline, x.create('t6')]);
await activateFiles('beta', betaId);
const rollback = await x.rollbackBeta(betaId);
expect(x.stagedFileChanges('campaign', rollback.deploymentId!, t6[0])).toHaveLength(1);
await activateFiles('campaign', rollback.deploymentId!);
expect(await readFile(config, 'utf8')).toBe('unrelated T6 config');
});
it('applies unscoped files to every selected server alongside game-specific files', async () => {
const x = await setup();
const shared = x.createScript({ name: 'Shared helper', targetRelPath: 'helper.gsc', content: Buffer.from('shared') });
const five = x.create('t5'), six = x.create('t6');
const { betaId } = await x.open([five, six, shared]);
expect(participants.map(server => x.listBetaScripts(betaId, server).map(file => file.script_id)))
.toEqual([[shared.script.id, five.script.id], [shared.script.id, six.script.id]]);
expect(x.stagedFileChanges('beta', String(betaId)).filter(row => row.rel_path === 'helper.gsc')).toHaveLength(2);
});
it('rejects untested files, unused servers and overlapping targets before saving a beta', async () => {
const x = await setup();
const five = x.create('t5'), six = x.create('t6');
await expect(x.open([five, six], [t5[0]])).rejects.toMatchObject({ kind: 'out-of-scope' });
await expect(x.open([five], participants)).rejects.toMatchObject({ kind: 'out-of-scope' });
const overlap = x.createScript({ name: 'Map overlap', targetRelPath: 'shared.gsc', scopeType: 'map', scopeValue: 'zombie_theater', content: Buffer.from('overlap') });
await expect(x.open([five, six, overlap])).rejects.toMatchObject({ kind: 'duplicate-target' });
const global = x.createScript({ name: 'Global overlap', targetRelPath: 'shared.gsc', content: Buffer.from('overlap') });
await expect(x.open([five, six, global])).rejects.toMatchObject({ kind: 'duplicate-target' });
const baselines = [
x.createScript({ name: 'Game baseline', kind: 'baseline', scopeType: 'game', scopeValue: 't5', content: Buffer.from('set sv_voice "1"\n') }),
x.createScript({ name: 'Map baseline', kind: 'baseline', scopeType: 'map', scopeValue: 'zombie_theater', content: Buffer.from('set sv_voice "0"\n') })
];
await expect(x.open(baselines, [t5[0]])).rejects.toMatchObject({ kind: 'duplicate-target' });
expect(x.db.prepare('SELECT * FROM betas').all()).toEqual([]);
});
});
+37
View File
@@ -68,6 +68,43 @@ async function tick(seconds = 15, players = 4, healthy = true) {
const scriptPath = (name: string) => join(fixture.t5, name, 'runtime/plutonium/storage/t5/raw/scripts/sp/test.gsc');
describe('durable beta test plans', () => {
it.each([
{ scope: 'quorum', fileScope: 'game', otherServer: 't6_zombie_town_1', otherMap: 'zm_transit' },
{ scope: 'game', fileScope: 'map', otherServer: 't5_zombie_cosmodrome_1', otherMap: 'zombie_cosmodrome' }
] as const)('requires a passing participant for every file with $scope coverage', async ({ scope, fileScope, otherServer, otherMap }) => {
const otherGame = otherServer.slice(0, 2);
await mkdir(otherGame === 't6' ? join(fixture.t6, otherServer, 'runtime/plutonium/storage/t6/scripts/zm')
: join(fixture.t5, otherServer, 'runtime/plutonium/storage/t5/raw/scripts/sp'), { recursive: true });
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
await seedServerRegistry();
const { createScript, getVersion } = await import('../../src/lib/server/fleet/scripts.js');
const { openBeta, getBeta, listBetaServers, promoteBeta } = await import('../../src/lib/server/fleet/beta.js');
const { testView } = await import('../../src/lib/server/fleet/beta-tests.js');
const files = [fileScope === 'game' ? 't5' : 'zombie_theater', fileScope === 'game' ? otherGame : otherMap]
.map(scopeValue => createScript({ name: `Test ${scopeValue}`, targetRelPath: `${scopeValue}.gsc`,
scopeType: fileScope, scopeValue, content: Buffer.from(`${scopeValue} beta`) }));
const { betaId } = await openBeta({
scripts: files.map(file => ({ scriptId: file.script.id, scriptVersionId: file.version.id })),
servers: [...names, otherServer], waitForHealth: true, waitForMatch: false,
testPlan: { ...defaultPlan(), scope, quorum: 1, requirements: [requirement('population', 'players')] }
});
await tick(); await tick();
await postTelemetry(gameEvent('game_start')); await tick();
expect(listBetaServers(betaId).find(server => server.server === names[0])?.stage).toBe('ready');
expect(getBeta(betaId)?.state).toBe('running');
expect(testView(betaId)?.outcome).toBe('active');
expect(files.map(file => getVersion(file.version.id)?.state)).toEqual(['untested', 'untested']);
await expect(promoteBeta(betaId)).rejects.toThrow('Test plan has not passed');
await postTelemetry(gameEvent('game_start', 25, 4, otherServer, otherMap)); await tick();
expect(getBeta(betaId)?.state).toBe('ready-promote');
expect(testView(betaId)?.outcome).toBe('passed');
expect(files.map(file => getVersion(file.version.id)?.state)).toEqual(['tested', 'tested']);
// Coverage still permits replicas to remain unproven once each file
// has a passing participant; it does not silently become an all gate.
expect(listBetaServers(betaId).find(server => server.server === names[1])?.stage).toBe('observing');
});
it('requires fresh gameplay after a revision and promotes exactly the replacement version', async () => {
const id = await openPlan({ ...defaultPlan(), requirements: [requirement('matches', 'matches')] });
await tick(); await tick();
+148
View File
@@ -0,0 +1,148 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
import { prepareNativeHost } from '../helpers/native-host.js';
vi.mock('../../src/lib/server/pipeline/worker.js', () => ({ kick: vi.fn() }));
let fixture: Fixture;
const t5 = ['t5_zombie_theater_1', 't5_zombie_theater_2', 't5_zombie_cosmodrome_1'];
const t6 = ['t6_zombie_town_1', 't6_zombie_town_2'];
beforeEach(async () => {
vi.resetModules();
fixture = await newFixture([...t5, ...t6]);
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
await seedServerRegistry();
const { db } = await import('../../src/lib/server/db/index.js');
const now = new Date().toISOString();
db().prepare(`UPDATE servers SET container_state='running', container_checked_at=?,
last_checked_at=?, last_seen_at=?, last_players=0`).run(now, now, now);
});
afterEach(async () => { await fixture.cleanup(); });
async function file(scopeType: 'game' | 'map' = 'game', scopeValue = 't5') {
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
return createScript({ name: `Test ${scopeValue}`, targetRelPath: `${scopeValue}.gsc`,
scopeType, scopeValue, content: Buffer.from('beta bytes') });
}
describe('automatic beta server selection', () => {
it('selects the requested count independently per game, least occupied first with deterministic ties', async () => {
const files = [await file(), await file('game', 't6')];
const { db } = await import('../../src/lib/server/db/index.js');
db().prepare('UPDATE servers SET last_players=4 WHERE name=?').run(t5[0]);
db().prepare('UPDATE servers SET last_players=2 WHERE name=?').run(t6[0]);
const { selectBetaServers } = await import('../../src/lib/server/fleet/beta-selection.js');
expect(selectBetaServers(files.map(f => f.script), { t5: 2, t6: 1 })).toEqual([t5[2], t5[1], t6[1]]);
});
it('accounts for occupied telemetry and fresh positive RCON evidence', async () => {
const { script } = await file();
const { db } = await import('../../src/lib/server/db/index.js');
const { selectBetaServers } = await import('../../src/lib/server/fleet/beta-selection.js');
const put = db().prepare('INSERT INTO server_telemetry(server,round,players,received_at,payload) VALUES(?,0,?,?,?)');
const report = (server: string, players: number) => ({ event: 'game_start', server, map: 'zombie_theater',
receivedAt: new Date().toISOString(), players: Array.from({ length: players }, (_, i) => ({ name: `P${i}`, guid: String(i) })) });
put.run(t5[2], 3, new Date().toISOString(), JSON.stringify(report(t5[2], 3)));
put.run(t5[0], 0, new Date().toISOString(), JSON.stringify(report(t5[0], 0)));
db().prepare('UPDATE servers SET last_players=2 WHERE name=?').run(t5[0]);
expect(selectBetaServers([script], { t5: 1 })).toEqual([t5[1]]);
});
it.each(['stale', 'stopped', 'destroyed', 'provisioning', 'beta', 'campaign'])('excludes %s servers', async reason => {
const { script, version } = await file();
const { db } = await import('../../src/lib/server/db/index.js');
const handle = db(), server = t5[2];
if (reason === 'stale') handle.prepare("UPDATE servers SET last_seen_at='2000-01-01T00:00:00Z' WHERE name=?").run(server);
if (reason === 'stopped') handle.prepare("UPDATE servers SET desired_state='stopped' WHERE name=?").run(server);
if (reason === 'destroyed') handle.prepare("UPDATE servers SET destroyed_at='2020-01-01T00:00:00Z' WHERE name=?").run(server);
if (reason === 'provisioning') handle.prepare(`INSERT INTO server_deployments(id,server,action,state,stage,started_at,triggered_by)
VALUES('busy',?,'destroy','queued','Queued',?,'test')`).run(server, new Date().toISOString());
if (reason === 'beta') {
const { openBeta } = await import('../../src/lib/server/fleet/beta.js');
await openBeta({ scripts: [{ scriptId: script.id, scriptVersionId: version.id }], servers: [server], waitForHealth: false, waitForMatch: false });
}
if (reason === 'campaign') {
handle.prepare("INSERT INTO deployments(id,action,triggered_by,started_at) VALUES('campaign','promote','test',?)").run(new Date().toISOString());
handle.prepare("INSERT INTO deployment_servers(deployment_id,server,files_changed,restart_state) VALUES('campaign',?,1,'pending')").run(server);
}
const { selectBetaServers } = await import('../../src/lib/server/fleet/beta-selection.js');
expect(selectBetaServers([script], { t5: 1 })).toEqual([t5[0]]);
});
it.each(['disabled', 'draining', 'disconnected', 'deleted'])('excludes instances on a %s host despite fresh game status', async state => {
const { script } = await file();
const { host } = await prepareNativeHost([t5[2]]);
const { db } = await import('../../src/lib/server/db/index.js');
if (state === 'disconnected') db().prepare('UPDATE ec_hosts SET last_heartbeat=NULL WHERE id=?').run(host.id);
else if (state === 'deleted') db().prepare('UPDATE ec_hosts SET deleted_at=? WHERE id=?').run(new Date().toISOString(), host.id);
else db().prepare('UPDATE ec_hosts SET mode=? WHERE id=?').run(state, host.id);
const { selectBetaServers } = await import('../../src/lib/server/fleet/beta-selection.js');
expect(selectBetaServers([script], { t5: 1 })).toEqual([t5[0]]);
});
it('covers every file scope instead of filling all slots from the emptiest map', async () => {
const files = [await file('map', 'zombie_theater'), await file('map', 'zombie_cosmodrome')];
const { db } = await import('../../src/lib/server/db/index.js');
db().prepare('UPDATE servers SET last_players=3 WHERE name=?').run(t5[2]);
const { selectBetaServers } = await import('../../src/lib/server/fleet/beta-selection.js');
expect(selectBetaServers(files.map(f => f.script), { t5: 2 })).toEqual([t5[0], t5[2]]);
expect(() => selectBetaServers(files.map(f => f.script), { t5: 1 })).toThrow('every selected file');
});
it('reserves game slots when a map can be tested in either game', async () => {
const files = [await file('map', 'shared'), await file('map', 'only_t5')];
const { db } = await import('../../src/lib/server/db/index.js');
db().prepare("UPDATE servers SET map='shared'").run();
db().prepare("UPDATE servers SET map='only_t5', last_players=4 WHERE name=?").run(t5[2]);
const { selectBetaServers } = await import('../../src/lib/server/fleet/beta-selection.js');
expect(selectBetaServers(files.map(f => f.script), { t5: 1, t6: 1 })).toEqual([t6[0], t5[2]]);
});
it('fails insufficient capacity or untested files before creating a beta', async () => {
const files = [await file(), await file('game', 't6')];
const { POST } = await import('../../src/routes/api/betas/+server.js');
const scripts = files.map(f => ({ scriptId: f.script.id, scriptVersionId: f.version.id }));
for (const instancesPerGame of [{ t5: 4, t6: 1 }, { t5: 1 }]) {
const response = await POST({ request: new Request('http://test/api/betas', {
method: 'POST', body: JSON.stringify({ scripts, instancesPerGame })
}) });
expect(response.status).toBe(409);
}
const { db } = await import('../../src/lib/server/db/index.js');
expect(db().prepare('SELECT * FROM betas').all()).toEqual([]);
});
it('accepts automatic API setup and requires every selected instance to pass even with a quorum preset', async () => {
const files = [await file(), await file('game', 't6')];
const { POST } = await import('../../src/routes/api/betas/+server.js');
const { defaultPlan } = await import('../../src/lib/beta-plan.js');
const response = await POST({ request: new Request('http://test/api/betas', { method: 'POST', body: JSON.stringify({
scripts: files.map(f => ({ scriptId: f.script.id, scriptVersionId: f.version.id })), instancesPerGame: { t5: 2, t6: 1 },
testPlan: { ...defaultPlan(), scope: 'quorum', quorum: 1 }
}) }) });
expect(response.status).toBe(200);
const body = await response.json();
expect(body.result.servers).toEqual([t5[2], t5[0], t6[0]]);
const { storedPlan } = await import('../../src/lib/server/fleet/beta-tests.js');
expect(JSON.parse(storedPlan(body.result.betaId)!.plan).scope).toBe('all');
const { db } = await import('../../src/lib/server/db/index.js');
expect(db().prepare('SELECT * FROM beta_server_scripts').all()).toHaveLength(3);
});
it.each([{}, { t5: 0 }, { t5: -1 }, { t5: 1.5 }, { t5: '2' }, { t5: 1001 }, { t7: 1 }, [], null])('rejects malformed counts %j', async instancesPerGame => {
const { script, version } = await file();
const { POST } = await import('../../src/routes/api/betas/+server.js');
const response = await POST({ request: new Request('http://test/api/betas', { method: 'POST', body: JSON.stringify({
scripts: [{ scriptId: script.id, scriptVersionId: version.id }], instancesPerGame
}) }) });
expect(response.status).toBe(400);
});
it('rejects ambiguous simultaneous manual and automatic targeting', async () => {
const { script, version } = await file();
const { POST } = await import('../../src/routes/api/betas/+server.js');
const response = await POST({ request: new Request('http://test/api/betas', { method: 'POST', body: JSON.stringify({
scripts: [{ scriptId: script.id, scriptVersionId: version.id }], instancesPerGame: { t5: 1 }, servers: [t5[0]]
}) }) });
expect(response.status).toBe(400);
});
});
+41 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { defaultPlan, betaPlanIssues, requirement } from '../../src/lib/beta-plan.js';
import { readBetaDraft, compatibleBetaServers } from '../../src/lib/beta-draft.js';
import { readBetaDraft, compatibleBetaServers, betaCandidateServers, betaApplicableGames, betaInstanceCounts, betaScopeMatches } from '../../src/lib/beta-draft.js';
import { betaWaitingCondition, betaEvidenceSummary } from '../../src/lib/beta-evidence.js';
describe('beta draft and plan recovery', () => {
@@ -9,10 +9,24 @@ describe('beta draft and plan recovery', () => {
const raw = JSON.stringify({ version: 1, savedAt: new Date().toISOString(), step: 2, picks: [{ scriptId: 12, versionId: 45 }], servers: ['town'], testPlan: { ...plan, timeoutHours: null } });
const draft = readBetaDraft(raw)!;
expect(draft.picks).toEqual([{ scriptId: 12, versionId: 45 }]);
expect(draft.selectionMode).toBe('manual');
expect(draft.servers).toEqual(['town']);
expect(betaPlanIssues(draft.testPlan, 1)).toContainEqual(expect.objectContaining({ field: 'timeoutHours' }));
expect(readBetaDraft('{bad')).toBeNull();
expect(readBetaDraft(JSON.stringify({ version: 1, picks: [{ scriptId: '12', versionId: 45 }] }))).toBeNull();
});
it('restores automatic selection and incomplete counts without substituting manual selections', () => {
const raw = { version: 1, savedAt: '', step: 1, picks: [{ scriptId: 12, versionId: 45 }],
servers: ['previous-manual-choice'], selectionMode: 'automatic', instancesPerGame: { t5: 2, t6: null }, testPlan: defaultPlan() };
expect(readBetaDraft(JSON.stringify(raw))).toMatchObject({ selectionMode: 'automatic', instancesPerGame: { t5: 2, t6: null } });
expect(readBetaDraft(JSON.stringify({ ...raw, instancesPerGame: { t5: 1.5, t6: -1 } }))?.instancesPerGame).toEqual({ t5: 1.5, t6: -1 });
expect(readBetaDraft(JSON.stringify({ ...raw, instancesPerGame: { t5: '2' } }))).toBeNull();
expect(readBetaDraft(JSON.stringify({ ...raw, instancesPerGame: { unknown: 2 } }))).toBeNull();
expect(readBetaDraft(JSON.stringify({ ...raw, selectionMode: 'unknown' }))).toBeNull();
});
it('defaults a legacy draft with no server choices to automatic selection', () => {
expect(readBetaDraft(JSON.stringify({ version: 1, picks: [{ scriptId: 12, versionId: 45 }], servers: [], testPlan: defaultPlan() }))?.selectionMode).toBe('automatic');
});
it('keeps compatible explicit selections without selecting newly eligible servers', () => {
const result = compatibleBetaServers(['town1', 'town2'], ['town2', 'town3']);
expect([...result.kept]).toEqual(['town2']);
@@ -24,6 +38,32 @@ describe('beta draft and plan recovery', () => {
expect(betaPlanIssues(plan, 2).find(issue => issue.field === 'matches-target')?.requirementId).toBe('matches');
});
});
describe('multi-game beta setup', () => {
const servers = [
{ name: 'kino', game: 't5', map: 'zombie_theater' },
{ name: 'town', game: 't6', map: 'zm_transit' },
{ name: 'origins', game: 't6', map: 'zm_tomb' }
];
it('offers the union of different file scopes while keeping file-to-server matches separate', () => {
const files = [{ scope_type: 'game' as const, scope_value: 't5' }, { scope_type: 'map' as const, scope_value: 'zm_transit' }];
expect(betaCandidateServers(files, servers).map(server => server.name)).toEqual(['kino', 'town']);
expect(betaApplicableGames(files, servers)).toEqual(['t5', 't6']);
expect(betaScopeMatches(files[0], servers[1])).toBe(false);
expect(betaScopeMatches(files[1], servers[0])).toBe(false);
});
it('includes every server for an unscoped file and requires a selected file first', () => {
expect(betaCandidateServers([{ scope_type: null, scope_value: null }], servers)).toEqual(servers);
expect(betaCandidateServers([], servers)).toEqual([]);
expect(betaApplicableGames([], servers)).toEqual([]);
});
it('keeps explicitly scoped games visible without fleet matches and preserves zero or incomplete counts', () => {
const files = [{ scope_type: 'game' as const, scope_value: 't5' }, { scope_type: 'game' as const, scope_value: 't6' }];
expect(betaApplicableGames(files, [])).toEqual(['t5', 't6']);
expect(betaInstanceCounts(['t5', 't6'], {})).toEqual({ t5: 1, t6: 1 });
expect(betaInstanceCounts(['t5', 't6'], { t5: 0, t6: null })).toEqual({ t5: 0, t6: null });
expect(betaInstanceCounts(['t6'], { t5: 3, t6: 2 })).toEqual({ t6: 2 });
});
});
describe('beta evidence explanations', () => {
const observation = { lastGameplayAt: new Date().toISOString(), lastEvent: 'round_change', players: 2, round: 12, matchStarted: true, gameEnded: false, healthy: true, healthCheckedAt: new Date().toISOString() };
it('distinguishes missing reports, insufficient population, and report-driven time', () => {