Files
electric-cherry/tests/unit/drift-kinds.test.ts
T
joshandClaude Opus 4.7 13a29e5ace Add deploy + destroy server flow; migrate to t5_<mode>_<slug>_<n> naming
Full server lifecycle management from Electric Cherry: pick game/mode/map/pluto
key in the Fleet page, EC provisions the deployment via Komodo, allocates a
port in 28960-28980, creates the 0o777 host dir, and starts the container.
Destroy button tears everything down (Komodo Destroy+Delete, rm -rf host dir,
transactional DB cleanup) and surfaces any partial-failure warnings inline.

Same phase cuts over to the new naming scheme (t5_zombie_cosmodrome_1 etc.).
Komodo entity names now match EC server names 1:1 (no more _/- transform), and
the map catalog carries friendly/slug/canonical for each of the 11 t5 zombie
maps. Server rows gain game_mode, map_slug, host_dir, config_file, game_id,
image, created_by, and destroyed_at columns for provisioning + audit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-09-13 18:38:42 -04:00

192 lines
7.8 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { newFixture, type Fixture } from '../helpers/fixture.js';
let fixture: Fixture;
beforeEach(async () => {
vi.resetModules();
fixture = await newFixture(['t5_zombie_theater_1']);
delete process.env.KOMODO_ADDRESS;
delete process.env.RCON_PASSWORD;
// Ensure the three per-kind destinations exist under the fixture tree.
await mkdir(join(fixture.t5, 't5_zombie_theater_1', 'runtime/plutonium/storage/t5/raw/scripts/sp'), { recursive: true });
await mkdir(join(fixture.t5, 't5_zombie_theater_1', 'configs'), { recursive: true });
await mkdir(join(fixture.t5, 't5_zombie_theater_1', 'runtime/plutonium/plugins'), { recursive: true });
});
afterEach(async () => {
await fixture.cleanup();
});
describe('drift scanning', () => {
it('detects untracked files across script, config, and plugin destinations', async () => {
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
const { scanServerDrift } = await import('../../src/lib/server/fleet/drift.js');
await seedServerRegistry();
// Drop one stray file in each managed destination.
const server = 't5_zombie_theater_1';
await writeFile(
join(fixture.t5, server, 'runtime/plutonium/storage/t5/raw/scripts/sp', 'stray.gsc'),
'main(){}\n'
);
await writeFile(
join(fixture.t5, server, 'configs', 'stray.cfg'),
'set foo "bar"\n'
);
await writeFile(
join(fixture.t5, server, 'runtime/plutonium/plugins', 'stray.dll'),
Buffer.from([0xde, 0xad])
);
const drift = await scanServerDrift(server);
const untracked = drift.filter((d) => d.driftKind === 'untracked');
const byKind = Object.fromEntries(untracked.map((d) => [d.kind, d.relPath]));
expect(byKind.script).toBe('stray.gsc');
expect(byKind.config).toBe('stray.cfg');
expect(byKind.plugin).toBe('stray.dll');
expect(untracked.length).toBe(3);
});
});
// Deploys a promoted artifact to a scope-matching server, then deletes it
// off disk so drift's "missing" pass has something to find.
async function promoteAndOrphan(
createScript: (i: {
name: string;
kind: 'script' | 'plugin' | 'baseline' | 'config';
scopeType?: 'game' | 'map';
scopeValue?: string;
targetRelPath?: string;
content: Buffer;
}) => { script: { id: number }; version: { id: number } },
getLatestVersion: (id: number) => { id: number } | undefined,
openBeta: (i: {
scripts: Array<{ scriptId: number; scriptVersionId: number }>;
servers: string[];
waitForHealth: boolean;
waitForMatch: boolean;
}) => Promise<{ betaId: number }>,
promoteBeta: (id: number) => Promise<unknown>,
db: () => { prepare: (q: string) => { run: (...args: unknown[]) => unknown } }
): Promise<{ scriptId: number }> {
const created = createScript({
name: 't5 hud',
kind: 'script',
scopeType: 'game',
scopeValue: 't5',
targetRelPath: 'hud.gsc',
content: Buffer.from('main(){}\n')
});
const ver = getLatestVersion(created.script.id)!;
const { betaId } = await openBeta({
scripts: [{ scriptId: created.script.id, scriptVersionId: ver.id }],
servers: ['t5_zombie_theater_1'],
waitForHealth: false,
waitForMatch: false
});
db().prepare(
`UPDATE beta_servers SET stage = 'ready', restarted_at = ? WHERE beta_id = ?`
).run(new Date().toISOString(), betaId);
db().prepare(`UPDATE betas SET state = 'ready-promote' WHERE id = ?`).run(betaId);
await promoteBeta(betaId);
return { scriptId: created.script.id };
}
describe('missing artifact detection', () => {
it('flags a promoted script that is absent on a scope-matching server as missing, then restores it on reconcile', async () => {
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
const { createScript, getLatestVersion } = await import(
'../../src/lib/server/fleet/scripts.js'
);
const { openBeta, promoteBeta } = await import(
'../../src/lib/server/fleet/beta.js'
);
const { scanServerDrift, runDriftDeployment } = await import(
'../../src/lib/server/fleet/drift.js'
);
const { db } = await import('../../src/lib/server/db/index.js');
await seedServerRegistry();
await promoteAndOrphan(
createScript as never,
getLatestVersion as never,
openBeta as never,
promoteBeta as never,
db as never
);
const targetPath = join(
fixture.t5,
't5_zombie_theater_1',
'runtime/plutonium/storage/t5/raw/scripts/sp',
'hud.gsc'
);
// Sanity check: promoted script lands on disk.
expect(existsSync(targetPath)).toBe(true);
// Simulate someone deleting the file out-of-band.
await rm(targetPath, { force: true });
const drift = await scanServerDrift('t5_zombie_theater_1');
const missing = drift.filter((d) => d.driftKind === 'missing' && d.kind === 'script');
expect(missing.length).toBe(1);
expect(missing[0].relPath).toBe('hud.gsc');
const result = await runDriftDeployment({
servers: ['t5_zombie_theater_1'],
dryRun: false,
triggeredBy: 'ui'
});
expect(result.totalRestored).toBeGreaterThanOrEqual(1);
expect(existsSync(targetPath)).toBe(true);
const restored = await readFile(targetPath);
expect(restored.toString()).toBe('main(){}\n');
});
it('flags a scope-matching server whose composed dedicated_sp.cfg is missing', async () => {
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
const { createScript, getLatestVersion } = await import(
'../../src/lib/server/fleet/scripts.js'
);
const { openBeta, promoteBeta } = await import(
'../../src/lib/server/fleet/beta.js'
);
const { scanServerDrift } = await import('../../src/lib/server/fleet/drift.js');
const { db } = await import('../../src/lib/server/db/index.js');
await seedServerRegistry();
const baseline = createScript({
name: 't5 baseline',
kind: 'baseline',
scopeType: 'game',
scopeValue: 't5',
content: Buffer.from('set g_inactivity "190"\n')
});
const bVer = getLatestVersion(baseline.script.id)!;
const { betaId } = await openBeta({
scripts: [{ scriptId: baseline.script.id, scriptVersionId: bVer.id }],
servers: ['t5_zombie_theater_1'],
waitForHealth: false,
waitForMatch: false
});
db().prepare(
`UPDATE beta_servers SET stage = 'ready', restarted_at = ? WHERE beta_id = ?`
).run(new Date().toISOString(), betaId);
db().prepare(`UPDATE betas SET state = 'ready-promote' WHERE id = ?`).run(betaId);
await promoteBeta(betaId);
const cfgPath = join(fixture.t5, 't5_zombie_theater_1', 'configs', 'dedicated_sp.cfg');
expect(existsSync(cfgPath)).toBe(true);
await rm(cfgPath, { force: true });
const drift = await scanServerDrift('t5_zombie_theater_1');
const missingCfg = drift.filter((d) => d.driftKind === 'missing' && d.kind === 'config');
expect(missingCfg.length).toBe(1);
expect(missingCfg[0].relPath).toBe('dedicated_sp.cfg');
});
});