Refresh Configs with browser editing and source inspection
This commit is contained in:
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { inspectConfig, type ConfigLayer } from '../../src/lib/server/fleet/config-inspect.js';
|
||||
import { composeServerCfg } from '../../src/lib/server/fleet/config-compose.js';
|
||||
import { changeConfigValue, configWarnings } from '../../src/lib/config-workbench.js';
|
||||
|
||||
const base = (text: string): ConfigLayer => ({ text, source: { id: 1, name: 'Base', version: 2, kind: 'baseline' } });
|
||||
const overlay = (id: number, text: string): ConfigLayer => ({ text, source: { id, name: `Overlay ${id}`, version: 3, kind: 'config' } });
|
||||
|
||||
describe('config source inspection', () => {
|
||||
it('traces the baseline and every override in actual ID order', () => {
|
||||
const baseline = base('set g_inactivity "190"\r\nset sv_voice "1"\r\n');
|
||||
const overlays = [overlay(5, 'set g_inactivity "60"'), overlay(2, 'set g_inactivity "0"')];
|
||||
const result = inspectConfig(baseline, overlays, 't5', 'zombie_theater');
|
||||
expect(result.text).toBe(composeServerCfg(baseline.text, overlays.map(o => ({ id: o.source.id!, text: o.text })), 't5', 'zombie_theater'));
|
||||
const setting = result.settings.find(s => s.key === 'g_inactivity')!;
|
||||
expect(setting.value).toBe('"60"');
|
||||
expect(setting.source.id).toBe(5);
|
||||
expect(setting.history.map(h => h.source.id)).toEqual([1, 2, 5]);
|
||||
expect(result.settings.find(s => s.key === 'sv_voice')?.source.id).toBe(1);
|
||||
});
|
||||
|
||||
it('explains duplicates within baselines and within overlays', () => {
|
||||
const result = inspectConfig(base('set x 1\nset x 2'), [overlay(2, 'set x 3\nset x 4')], null, null);
|
||||
expect(result.text).toBe('set x 1\nset x 4\n');
|
||||
expect(result.settings[0].history.map(h => h.value)).toEqual(['1', '2', '3', '4']);
|
||||
expect(result.settings[0].value).toBe('4');
|
||||
expect(result.warnings.filter(w => w.includes('also assigned'))).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('uses final file order for differently cased keys rather than assuming layer order', () => {
|
||||
const result = inspectConfig(base('set X 1'), [overlay(2, 'set x 2'), overlay(3, 'set X 3')], null, null);
|
||||
expect(result.text).toContain('set X 3');
|
||||
expect(result.settings).toHaveLength(1);
|
||||
expect(result.settings[0].value).toBe('2');
|
||||
expect(result.settings[0].source.id).toBe(2);
|
||||
});
|
||||
|
||||
it('traces map overrides across set, seta, and bare assignments', () => {
|
||||
const result = inspectConfig(base('set sv_maprotation "old"\nseta SV_MAPROTATION "older"\nsv_maprotation "last"'), [overlay(2, 'set sv_maprotation "overlay"')], 't5', 'zombie_theater');
|
||||
const setting = result.settings[0];
|
||||
expect(setting.key).toBe('sv_maprotation');
|
||||
expect(setting.value).toBe('"map zombie_theater"');
|
||||
expect(setting.source.kind).toBe('map');
|
||||
expect(setting.history.map(h => h.value)).toEqual(['"old"', '"overlay"', '"older"', '"last"', '"map zombie_theater"']);
|
||||
expect(result.text.match(/set sv_maprotation/g)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('preserves T6 map variants and does not invent map selection for unknown maps', () => {
|
||||
const t6 = inspectConfig(base('set sv_voice 1'), [], 't6', 'zm_transit', 'town');
|
||||
expect(t6.settings.find(s => s.key === 'sv_maprotation')?.value).toContain('execgts zm_standard_town.cfg');
|
||||
const legacy = inspectConfig(base('set sv_maprotation "custom"'), [], 't5', 'unknown');
|
||||
expect(legacy.settings[0].source.kind).toBe('baseline');
|
||||
expect(legacy.text).toBe('set sv_maprotation "custom"\n');
|
||||
});
|
||||
|
||||
it('reports ignored overlay commands while retaining raw baseline commands', () => {
|
||||
const result = inspectConfig(base('exec custom.cfg\nset a 1'), [overlay(2, 'seta a 2\nSET a 3\n// note\nset b 4')], null, null);
|
||||
expect(result.warnings.filter(w => w.includes('ignored'))).toHaveLength(2);
|
||||
expect(result.text).toContain('exec custom.cfg');
|
||||
expect(result.settings.find(s => s.key === 'a')?.value).toBe('1');
|
||||
expect(result.settings.find(s => s.key === 'b')?.source.id).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('config settings editor', () => {
|
||||
it('changes only the selected assignment, preserving CRLF, spacing and raw commands', () => {
|
||||
const text = '// note\r\n set a "1" // original\r\nexec other.cfg\r\nset a 5\n';
|
||||
expect(changeConfigValue(text, 1, '"2" // changed')).toBe('// note\r\n set a "2" // changed\r\nexec other.cfg\r\nset a 5\n');
|
||||
});
|
||||
it('does not silently rewrite unsupported lines and reports missing values', () => {
|
||||
expect(changeConfigValue('seta a 1', 0, '2')).toBe('seta a 1');
|
||||
expect(configWarnings('set a // missing', true)).toContain('Line 1: a has no value.');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||
|
||||
let fixture: Fixture;
|
||||
const server = 't5_zombie_theater_1';
|
||||
beforeEach(async () => {
|
||||
vi.resetModules(); fixture = await newFixture([server]);
|
||||
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
|
||||
await seedServerRegistry();
|
||||
});
|
||||
afterEach(async () => { await fixture.cleanup(); });
|
||||
|
||||
async function seed() {
|
||||
const scripts = await import('../../src/lib/server/fleet/scripts.js');
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const baseline = scripts.createScript({ name: 'Base', kind: 'baseline', scopeType: 'game', scopeValue: 't5', content: Buffer.from('set g_inactivity 190') });
|
||||
db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(baseline.version.id, baseline.script.id);
|
||||
return { ...scripts, baseline, db };
|
||||
}
|
||||
|
||||
describe('config workbench API', () => {
|
||||
it('previews a draft without writing versions or changing the promoted config', async () => {
|
||||
const { createScript, baseline, listVersions, getScript } = await seed();
|
||||
const overlay = createScript({ name: 'AFK', kind: 'config', scopeType: 'map', scopeValue: 'zombie_theater', content: Buffer.from('set g_inactivity 0') });
|
||||
const { POST, GET } = await import('../../src/routes/api/configs/inspect/+server.js');
|
||||
const before = await (await GET({ url: new URL(`http://localhost/api/configs/inspect?server=${server}`) })).json();
|
||||
expect(before.inspection.settings.find((s: { key: string }) => s.key === 'g_inactivity').value).toBe('190');
|
||||
const response = await POST({ request: new Request('http://localhost', { method: 'POST', body: JSON.stringify({ server, artifactId: overlay.script.id, text: 'set g_inactivity 60' }) }) });
|
||||
expect(response.status).toBe(200);
|
||||
const result = await response.json();
|
||||
expect(result.inspection.settings.find((s: { key: string }) => s.key === 'g_inactivity')).toMatchObject({ value: '60', source: { id: overlay.script.id, draft: true } });
|
||||
expect(listVersions(overlay.script.id)).toHaveLength(1);
|
||||
expect(getScript(baseline.script.id)?.current_version_id).toBe(baseline.version.id);
|
||||
const { getPromotedServerConfig } = await import('../../src/lib/server/fleet/server-config.js');
|
||||
expect(before.inspection.text).toBe(getPromotedServerConfig('t5', 'zombie_theater')!.toString('utf8'));
|
||||
});
|
||||
|
||||
it('uses promoted versions even when a newer untested version exists', async () => {
|
||||
const { baseline, uploadNewVersion } = await seed();
|
||||
uploadNewVersion(baseline.script.id, Buffer.from('set g_inactivity 0'));
|
||||
const { GET } = await import('../../src/routes/api/configs/inspect/+server.js');
|
||||
const result = await (await GET({ url: new URL(`http://localhost/?server=${server}`) })).json();
|
||||
expect(result.inspection.settings.find((s: { key: string }) => s.key === 'g_inactivity').value).toBe('190');
|
||||
expect(result.inspection.sources[0].version).toBe(1);
|
||||
});
|
||||
|
||||
it('handles missing baselines and allows previewing an unpromoted baseline draft', async () => {
|
||||
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
|
||||
const { GET, POST } = await import('../../src/routes/api/configs/inspect/+server.js');
|
||||
const missing = await (await GET({ url: new URL(`http://localhost/?server=${server}`) })).json();
|
||||
expect(missing.inspection.reason).toMatch(/No promoted baseline/);
|
||||
const baseline = createScript({ name: 'Base', kind: 'baseline', scopeType: 'game', scopeValue: 't5', content: Buffer.from('set a 1') });
|
||||
const response = await POST({ request: new Request('http://localhost', { method: 'POST', body: JSON.stringify({ server, artifactId: baseline.script.id, text: 'set a 2' }) }) });
|
||||
expect((await response.json()).inspection.settings.find((s: { key: string }) => s.key === 'a').value).toBe('2');
|
||||
});
|
||||
|
||||
it('rejects mismatched scopes and malformed preview requests', async () => {
|
||||
const { createScript } = await seed();
|
||||
const config = createScript({ name: 'T6', kind: 'config', scopeType: 'game', scopeValue: 't6', content: Buffer.from('set a 1') });
|
||||
const { POST } = await import('../../src/routes/api/configs/inspect/+server.js');
|
||||
const response = await POST({ request: new Request('http://localhost', { method: 'POST', body: JSON.stringify({ server, artifactId: config.script.id, text: 'set a 2' }) }) });
|
||||
expect(response.status).toBe(400);
|
||||
expect((await response.json()).error).toMatch(/does not apply/);
|
||||
expect((await POST({ request: new Request('http://localhost', { method: 'POST', body: '{' }) })).status).toBe(400);
|
||||
});
|
||||
|
||||
it('reads only versions owned by the requested config and rejects other artifact kinds', async () => {
|
||||
const { baseline, createScript } = await seed();
|
||||
const other = createScript({ name: 'Other', kind: 'config', scopeType: 'game', scopeValue: 't5', content: Buffer.from('set b 1') });
|
||||
const script = createScript({ name: 'Script', targetRelPath: 'test.gsc', content: Buffer.from('code') });
|
||||
const { GET } = await import('../../src/routes/api/configs/[id]/+server.js');
|
||||
const read = (id: number | string, version?: number) => GET({ params: { id: String(id) }, url: new URL(`http://localhost/${version ? `?version=${version}` : ''}`) });
|
||||
expect((await (await read(baseline.script.id)).json()).text).toBe('set g_inactivity 190');
|
||||
expect((await read(baseline.script.id, other.version.id)).status).toBe(404);
|
||||
expect((await read(script.script.id)).status).toBe(404);
|
||||
expect((await read('1oops')).status).toBe(400);
|
||||
});
|
||||
|
||||
it('saves editor content as an untested version without promoting it', async () => {
|
||||
const { baseline, getScript, getLatestVersion } = await seed();
|
||||
const { POST } = await import('../../src/routes/api/scripts/[id]/versions/+server.js');
|
||||
const form = new FormData(); form.append('content', new Blob(['set g_inactivity 0']), 'config.cfg'); form.append('notes', 'Disable timeout');
|
||||
const response = await POST({ params: { id: String(baseline.script.id) }, request: new Request('http://localhost', { method: 'POST', body: form }) });
|
||||
expect(response.status).toBe(200);
|
||||
expect(getLatestVersion(baseline.script.id)).toMatchObject({ version: 2, state: 'untested', notes: 'Disable timeout' });
|
||||
expect(getScript(baseline.script.id)?.current_version_id).toBe(baseline.version.id);
|
||||
});
|
||||
|
||||
it('blocks config saves during an active beta', async () => {
|
||||
const { baseline, db, listVersions } = await seed();
|
||||
const beta = db().prepare("INSERT INTO betas (script_id, script_version_id, trigger_kind, state) VALUES (?, ?, 'manual', 'running')").run(baseline.script.id, baseline.version.id);
|
||||
db().prepare('INSERT INTO beta_scripts (beta_id, script_id, script_version_id) VALUES (?, ?, ?)').run(beta.lastInsertRowid, baseline.script.id, baseline.version.id);
|
||||
const { POST } = await import('../../src/routes/api/scripts/[id]/versions/+server.js');
|
||||
const form = new FormData(); form.append('content', new Blob(['set g_inactivity 0']), 'config.cfg');
|
||||
const response = await POST({ params: { id: String(baseline.script.id) }, request: new Request('http://localhost', { method: 'POST', body: form }) });
|
||||
expect(response.status).toBe(409);
|
||||
expect(listVersions(baseline.script.id)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user