Files
electric-cherry/tests/unit/beta-multigame.test.ts
T
2026-10-12 18:46:57 -04:00

305 lines
12 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({
releaseVersion: '1.0.0',
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,
releaseVersion: '1.0.1',
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`),
undefined,
'1.0.1'
)
);
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({
releaseVersion: '1.0.0',
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({
releaseVersion: '1.0.0',
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({
releaseVersion: '1.0.0',
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([]);
});
});