160 lines
10 KiB
TypeScript
160 lines
10 KiB
TypeScript
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([]);
|
|
});
|
|
});
|