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[], 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([]); }); });