292 lines
12 KiB
TypeScript
292 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({
|
|
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([]);
|
|
});
|
|
});
|