Require release versions for script uploads
CI / checks (push) Successful in 5m26s

This commit is contained in:
2026-10-12 18:46:57 -04:00
parent ffda04f276
commit 2f52dcb1ef
50 changed files with 871 additions and 113 deletions
+147
View File
@@ -0,0 +1,147 @@
import { afterEach, expect, it, vi } from 'vitest';
import { flushSync, mount, unmount } from 'svelte';
import BetaRevisionDialog from '../../src/lib/ui/components/BetaRevisionDialog.svelte';
import ScriptUploadDrawer from '../../src/lib/ui/components/ScriptUploadDrawer.svelte';
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
let component: ReturnType<typeof mount> | undefined;
afterEach(async () => {
if (component) await unmount(component);
component = undefined;
document.body.replaceChildren();
vi.unstubAllGlobals();
});
it('fills exact filenames from a manifest, preserves manual values, and sends each release', async () => {
const uploaded: FormData[] = [];
vi.stubGlobal(
'fetch',
vi.fn(async (url: string, options?: RequestInit) => {
if (url === '/api/scope-options') return Response.json({ games: [], mapsByGame: {} });
if (url === '/api/servers') return Response.json({ servers: [] });
if (url === '/api/scripts/inspect')
return Response.json({ sha256: 'test', duplicates: [] });
uploaded.push(options!.body as FormData);
return Response.json({
ok: true,
script: { id: uploaded.length },
versionId: uploaded.length
});
})
);
const drawer = mount(ScriptUploadDrawer, {
target: document.body,
props: { scripts: [], onuploaded: async () => {} }
});
component = drawer;
flushSync();
const dialog = document.querySelector('dialog')!;
dialog.showModal = () => {
dialog.open = true;
};
await drawer.addFiles([
new File(['hud'], 'hud.gsc'),
new File(['join'], 'join.gsc'),
new File(['lock'], 'lock.gsc')
]);
flushSync();
const versions = [...document.querySelectorAll<HTMLInputElement>('input[placeholder="1.2.0"]')];
expect(versions).toHaveLength(3);
const uploadButton = [...document.querySelectorAll<HTMLButtonElement>('button')].find(
(button) => button.textContent?.trim() === 'Upload'
)!;
expect(uploadButton.disabled).toBe(true);
versions[0].value = '3.0.0';
versions[0].dispatchEvent(new Event('input', { bubbles: true }));
flushSync();
const manifest = document.querySelector<HTMLInputElement>('input[accept=".json"]')!;
Object.defineProperty(manifest, 'files', {
configurable: true,
value: [
new File(['{"hud.gsc":"1.0.0","join.gsc":"2.0.0","LOCK.gsc":"9.0.0"}'], 'versions.json')
]
});
manifest.dispatchEvent(new Event('change', { bubbles: true }));
await vi.waitFor(() => {
flushSync();
expect(versions[1].value).toBe('2.0.0');
});
expect(versions[0].value).toBe('3.0.0');
expect(versions[2].value).toBe('');
expect(uploadButton.disabled).toBe(true);
versions[2].value = '1.0';
versions[2].dispatchEvent(new Event('input', { bubbles: true }));
flushSync();
expect(uploadButton.disabled).toBe(true);
versions[2].value = '1.0.0';
versions[2].dispatchEvent(new Event('input', { bubbles: true }));
flushSync();
expect(uploadButton.disabled).toBe(false);
uploadButton.click();
await vi.waitFor(() => expect(uploaded).toHaveLength(3));
expect(uploaded.map((form) => form.get('releaseVersion'))).toEqual(['3.0.0', '2.0.0', '1.0.0']);
});
it('requires a replacement release before beta review and includes it in the revision request', async () => {
let submitted: FormData | null = null;
const onUpdate = vi.fn();
vi.stubGlobal(
'fetch',
vi.fn(async (url: string, options?: RequestInit) => {
if (url === '/api/scripts/inspect')
return Response.json({ sha256: 'new-hash', duplicates: [] });
if (url.endsWith('/files/4')) return new Response('original script');
submitted = options!.body as FormData;
return Response.json({ ok: true });
})
);
component = mount(BetaRevisionDialog, {
target: document.body,
props: {
open: true,
betaId: 7,
revision: 1,
servers: [],
onUpdate,
files: [
{
id: 4,
name: 'HUD',
kind: 'script',
target_rel_path: 'hud.gsc',
version: { version: 1, release_version: '1.0.0', sha256: 'old-hash' }
}
]
}
});
flushSync();
const input = document.querySelector<HTMLInputElement>('input[type="file"]:not([accept])')!;
Object.defineProperty(input, 'files', {
configurable: true,
value: [new File(['updated script'], 'hud.gsc')]
});
input.dispatchEvent(new Event('change', { bubbles: true }));
await vi.waitFor(() => {
flushSync();
expect(document.querySelector('input[required]')).not.toBeNull();
});
const review = [...document.querySelectorAll<HTMLButtonElement>('button')].find(
(button) => button.textContent?.trim() === 'Review update'
)!;
expect(review.disabled).toBe(true);
const version = document.querySelector<HTMLInputElement>('input[required]')!;
version.value = '1.0.1';
version.dispatchEvent(new Event('input', { bubbles: true }));
flushSync();
expect(review.disabled).toBe(false);
review.click();
flushSync();
expect(document.body.textContent).toContain('Release 1.0.0 → 1.0.1');
const deploy = [...document.querySelectorAll<HTMLButtonElement>('button')].find(
(button) => button.textContent?.trim() === 'Deploy revision 2'
)!;
deploy.click();
await vi.waitFor(() => expect(onUpdate).toHaveBeenCalledOnce());
expect(submitted!.get('release:4')).toBe('1.0.1');
expect(submitted!.get('file:4')).toBeInstanceOf(File);
});
+3
View File
@@ -44,6 +44,7 @@ async function attachBeta(state = 'running', outcome?: string, deleted = false)
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
const { db } = await import('../../src/lib/server/db/index.js');
const { script, version } = createScript({
releaseVersion: '1.0.0',
name: 'Protected beta',
targetRelPath: 'protected.gsc',
scopeType: 'game',
@@ -171,6 +172,7 @@ describe('active beta destruction guard', () => {
expect(queued.ok).toBe(true);
if (!queued.ok) throw new Error('Expected queued removal');
const { script, version } = createScript({
releaseVersion: '1.0.0',
name: 'Beta admission',
targetRelPath: 'admission.gsc',
scopeType: 'game',
@@ -221,6 +223,7 @@ describe('active beta destruction guard', () => {
.prepare('UPDATE server_deployments SET state = ?, stage = ? WHERE id = ?')
.run(state, stage, operation);
const { script, version } = createScript({
releaseVersion: '1.0.0',
name: 'Beta admission',
targetRelPath: 'admission.gsc',
scopeType: 'game',
+1
View File
@@ -18,6 +18,7 @@ async function seedBeta(multiple: boolean) {
const games = multiple ? ['t5', 't6'] : ['t5'];
const scripts = games.map((game) => {
const { script } = createScript({
releaseVersion: '1.0.0',
name: `${game} test`,
targetRelPath: `${game}.gsc`,
scopeType: 'game',
+15 -2
View File
@@ -28,6 +28,7 @@ async function setup() {
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',
@@ -82,7 +83,11 @@ describe('multi-game beta bundles', () => {
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') }
{
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])
@@ -115,7 +120,12 @@ describe('multi-game beta bundles', () => {
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`))
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
@@ -221,6 +231,7 @@ describe('multi-game beta bundles', () => {
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')
@@ -250,6 +261,7 @@ describe('multi-game beta bundles', () => {
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',
@@ -260,6 +272,7 @@ describe('multi-game beta bundles', () => {
kind: 'duplicate-target'
});
const global = x.createScript({
releaseVersion: '1.0.0',
name: 'Global overlap',
targetRelPath: 'shared.gsc',
content: Buffer.from('overlap')
+8 -1
View File
@@ -84,6 +84,7 @@ async function openPlan(plan: BetaPlan, servers = [names[0]]) {
const { openBeta } = await import('../../src/lib/server/fleet/beta.js');
const { db } = await import('../../src/lib/server/db/index.js');
const { script } = createScript({
releaseVersion: '1.0.0',
name: 'Beta test',
targetRelPath: 'test.gsc',
content: Buffer.from('main() {}')
@@ -148,6 +149,7 @@ describe('durable beta test plans', () => {
fileScope === 'game' ? otherGame : otherMap
].map((scopeValue) =>
createScript({
releaseVersion: '1.0.0',
name: `Test ${scopeValue}`,
targetRelPath: `${scopeValue}.gsc`,
scopeType: fileScope,
@@ -218,7 +220,11 @@ describe('durable beta test plans', () => {
const file = listBetaScripts(id)[0];
expect(getBeta(id)?.state).toBe('ready-promote');
await reviseBeta(id, 1, [
{ scriptId: file.script_id, content: Buffer.from('main() { /* fixed */ }') }
{
scriptId: file.script_id,
releaseVersion: '1.0.1',
content: Buffer.from('main() { /* fixed */ }')
}
]);
const replacement = listBetaScripts(id)[0].script_version_id;
expect(replacement).not.toBe(file.script_version_id);
@@ -613,6 +619,7 @@ describe('durable beta test plans', () => {
const { openBeta, getBeta, listBetaServers } =
await import('../../src/lib/server/fleet/beta.js');
const { script, version } = createScript({
releaseVersion: '1.0.0',
name: 'Legacy beta',
targetRelPath: 'legacy.gsc',
content: Buffer.from('main() {}')
+1
View File
@@ -28,6 +28,7 @@ async function ready(kind: 'script' | 'baseline' = 'script', scope: 'game' | 'ma
const { openBeta } = await import('../../src/lib/server/fleet/beta.js');
const { db } = await import('../../src/lib/server/db/index.js');
const { script } = createScript({
releaseVersion: '1.0.0',
name: 'Review test',
kind,
targetRelPath: kind === 'script' ? 'review.gsc' : undefined,
+78 -14
View File
@@ -42,6 +42,7 @@ async function setup(
await writeFile(path, options.original);
}
const { script, version } = scripts.createScript({
releaseVersion: '1.0.0',
name: 'Revision test',
targetRelPath: kind === 'script' ? 'revision.gsc' : undefined,
kind,
@@ -89,7 +90,13 @@ describe('beta revisions', () => {
x.reviseBeta(
x.betaId,
1,
[{ scriptId: x.script.id, content: Buffer.from('revision two') }],
[
{
scriptId: x.script.id,
releaseVersion: '2.0.0',
content: Buffer.from('revision two')
}
],
'Small fix'
)
).resolves.toEqual({ revision: 2 });
@@ -132,7 +139,11 @@ describe('beta revisions', () => {
await activateFiles('beta', x.betaId);
for (let revision = 2; revision <= 3; revision++) {
await x.reviseBeta(x.betaId, revision - 1, [
{ scriptId: x.script.id, content: Buffer.from(`revision ${revision}`) }
{
scriptId: x.script.id,
releaseVersion: `${revision}.0.0`,
content: Buffer.from(`revision ${revision}`)
}
]);
await activateFiles('beta', x.betaId);
}
@@ -157,7 +168,11 @@ describe('beta revisions', () => {
await activateFiles('beta', x.betaId);
}
await x.reviseBeta(x.betaId, 1, [
{ scriptId: x.script.id, content: Buffer.from('pending replacement') }
{
scriptId: x.script.id,
releaseVersion: '2.0.0',
content: Buffer.from('pending replacement')
}
]);
if (waves)
expect(x.listBetaServers(x.betaId).map((row) => row.stage)).toEqual([
@@ -174,7 +189,9 @@ describe('beta revisions', () => {
it('does not restore or delete files when no revision was ever activated', async () => {
const x = await setup({ original: 'untouched' });
await x.reviseBeta(x.betaId, 1, [{ scriptId: x.script.id, content: Buffer.from('new') }]);
await x.reviseBeta(x.betaId, 1, [
{ scriptId: x.script.id, releaseVersion: '2.0.0', content: Buffer.from('new') }
]);
expect(await x.rollbackBeta(x.betaId)).toMatchObject({ deploymentId: null });
expect(await readFile(x.path, 'utf8')).toBe('untouched');
});
@@ -183,11 +200,16 @@ describe('beta revisions', () => {
const x = await setup({ original: 'tracked original' });
const { recordScript, getServerScript } =
await import('../../src/lib/server/fleet/inventory.js');
const prior = x.uploadNewVersion(x.script.id, Buffer.from('tracked original'));
const prior = x.uploadNewVersion(
x.script.id,
Buffer.from('tracked original'),
undefined,
'1.0.1'
);
recordScript(names[0], 'revision.gsc', prior.id, prior.sha256, prior.size, 'test');
await activateFiles('beta', x.betaId);
await x.reviseBeta(x.betaId, 1, [
{ scriptId: x.script.id, content: Buffer.from('replacement') }
{ scriptId: x.script.id, releaseVersion: '2.0.0', content: Buffer.from('replacement') }
]);
await activateFiles('beta', x.betaId);
expect(x.db.prepare('SELECT prior_version_id FROM beta_server_scripts').get()).toEqual({
@@ -210,7 +232,7 @@ describe('beta revisions', () => {
});
x.db.prepare("UPDATE beta_test_plans SET outcome = 'paused' WHERE beta_id = ?").run(betaId);
await x.reviseBeta(betaId, 1, [
{ scriptId: x.script.id, content: Buffer.from('replacement') }
{ scriptId: x.script.id, releaseVersion: '2.0.0', content: Buffer.from('replacement') }
]);
expect(x.listBetaServers(betaId).map((row) => [row.server, row.stage])).toEqual([
[names[0], 'queued'],
@@ -229,7 +251,11 @@ describe('beta revisions', () => {
releaseRestartRequest(names[0], { kind: 'beta', id: String(x.betaId) });
await expect(
x.reviseBeta(x.betaId, 1, [
{ scriptId: x.script.id, content: Buffer.from('replacement') }
{
scriptId: x.script.id,
releaseVersion: '2.0.0',
content: Buffer.from('replacement')
}
])
).resolves.toEqual({ revision: 2 });
});
@@ -238,7 +264,11 @@ describe('beta revisions', () => {
const x = await setup({ original: 'original config', kind: 'baseline' });
await activateFiles('beta', x.betaId);
await x.reviseBeta(x.betaId, 1, [
{ scriptId: x.script.id, content: Buffer.from('set sv_voice "0"\n') }
{
scriptId: x.script.id,
releaseVersion: '2.0.0',
content: Buffer.from('set sv_voice "0"\n')
}
]);
await activateFiles('beta', x.betaId);
expect(await readFile(x.path, 'utf8')).toContain('set sv_voice "0"');
@@ -259,7 +289,11 @@ describe('beta revisions', () => {
'activating'
])('rejects %s updates without saving a revision', async (reason) => {
const x = await setup();
const upload = { scriptId: x.script.id, content: Buffer.from('changed') };
const upload = {
scriptId: x.script.id,
releaseVersion: '2.0.0',
content: Buffer.from('changed')
};
if (reason === 'identical')
upload.content = Buffer.from(
(await import('../../src/lib/server/fleet/scripts.js')).getVersionContent(
@@ -301,7 +335,7 @@ describe('beta revisions', () => {
x.db.prepare('UPDATE betas SET opened_at = ?').run('2000-01-01T00:00:00Z');
x.db.prepare("UPDATE beta_test_plans SET outcome = 'inconclusive'").run();
await x.reviseBeta(x.betaId, 1, [
{ scriptId: x.script.id, content: Buffer.from('changed') }
{ scriptId: x.script.id, releaseVersion: '2.0.0', content: Buffer.from('changed') }
]);
const { closeDb } = await import('../../src/lib/server/db/index.js');
closeDb();
@@ -320,7 +354,7 @@ describe('beta revisions', () => {
const x = await setup({ legacy: true });
await activateFiles('beta', x.betaId);
await x.reviseBeta(x.betaId, 1, [
{ scriptId: x.script.id, content: Buffer.from('changed') }
{ scriptId: x.script.id, releaseVersion: '2.0.0', content: Buffer.from('changed') }
]);
expect(x.listBetaServers(x.betaId)[0]).toMatchObject({
stage: 'awaiting-empty',
@@ -346,8 +380,16 @@ describe('beta revisions', () => {
.run(x.betaId, overlay.script.id, overlay.version.id);
await expect(
x.reviseBeta(x.betaId, 1, [
{ scriptId: x.script.id, content: Buffer.from('valid new version') },
{ scriptId: overlay.script.id, content: Buffer.from('invalid config') }
{
scriptId: x.script.id,
releaseVersion: '2.0.0',
content: Buffer.from('valid new version')
},
{
scriptId: overlay.script.id,
releaseVersion: '2.0.0',
content: Buffer.from('invalid config')
}
])
).rejects.toThrow('at least one');
expect(x.listVersions(x.script.id)).toHaveLength(1);
@@ -365,6 +407,7 @@ describe('beta revisions', () => {
const form = new FormData();
form.set('expectedRevision', '1');
form.set('notes', 'Fix scoring');
form.set(`release:${x.script.id}`, '2.0.0');
form.set(`file:${x.script.id}`, new File(['changed'], 'revision.gsc'));
const post = () =>
POST({
@@ -380,4 +423,25 @@ describe('beta revisions', () => {
});
expect(await download.text()).toBe('revision one');
});
it.each([undefined, 'bad', '1.0.0'])(
'rejects invalid replacement release %s without modifying the beta',
async (release) => {
const x = await setup();
const { POST } = await import('../../src/routes/api/betas/[id]/revisions/+server.js');
const before = x.getBeta(x.betaId);
const form = new FormData();
form.set('expectedRevision', '1');
form.set(`file:${x.script.id}`, new File(['changed'], 'revision.gsc'));
if (release !== undefined) form.set(`release:${x.script.id}`, release);
const response = await POST({
params: { id: String(x.betaId) },
request: new Request('http://test/revisions', { method: 'POST', body: form })
});
expect(response.status).toBe(release === '1.0.0' ? 409 : 400);
expect(x.getBeta(x.betaId)).toEqual(before);
expect(x.listVersions(x.script.id)).toHaveLength(1);
expect(x.db.prepare('SELECT * FROM beta_revisions').all()).toEqual([]);
expect(x.listBetaScripts(x.betaId)[0].script_version_id).toBe(x.version.id);
}
);
});
+1
View File
@@ -27,6 +27,7 @@ afterEach(async () => {
async function file(scopeType: 'game' | 'map' = 'game', scopeValue = 't5') {
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
return createScript({
releaseVersion: '1.0.0',
name: `Test ${scopeValue}`,
targetRelPath: `${scopeValue}.gsc`,
scopeType,
+8 -2
View File
@@ -22,6 +22,7 @@ async function open(kind: 'script' | 'plugin' | 'baseline' = 'script') {
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
const { openBeta } = await import('../../src/lib/server/fleet/beta.js');
const { script, version } = createScript({
releaseVersion: '1.0.0',
name: `Staged ${kind}`,
kind,
targetRelPath:
@@ -274,7 +275,12 @@ describe('staged beta files', () => {
)
.run(promotion.deploymentId);
const { uploadNewVersion } = await import('../../src/lib/server/fleet/scripts.js');
const newer = uploadNewVersion(script.id, Buffer.from('newer beta bytes'));
const newer = uploadNewVersion(
script.id,
Buffer.from('newer beta bytes'),
undefined,
'1.0.1'
);
const request = openBeta({
scripts: [{ scriptId: script.id, scriptVersionId: newer.id }],
servers: [servers[1]],
@@ -296,7 +302,7 @@ describe('staged beta files', () => {
const { betaId: olderBeta, script } = await open(kind);
const { uploadNewVersion } = await import('../../src/lib/server/fleet/scripts.js');
const newestBytes = kind === 'script' ? 'newest tested bytes' : 'set sv_voice "0"\n';
const newer = uploadNewVersion(script.id, Buffer.from(newestBytes));
const newer = uploadNewVersion(script.id, Buffer.from(newestBytes), undefined, '1.0.2');
const { openBeta, promoteBeta } = await import('../../src/lib/server/fleet/beta.js');
const { betaId: newerBeta } = await openBeta({
scripts: [{ scriptId: script.id, scriptVersionId: newer.id }],
+1
View File
@@ -149,6 +149,7 @@ describe('config workbench API', () => {
content: Buffer.from('set b 1')
});
const script = createScript({
releaseVersion: '1.0.0',
name: 'Script',
targetRelPath: 'test.gsc',
content: Buffer.from('code')
+1
View File
@@ -28,6 +28,7 @@ async function open(planned = false, servers = [names[0]]) {
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
const { openBeta } = await import('../../src/lib/server/fleet/beta.js');
const { script, version } = createScript({
releaseVersion: '1.0.0',
name: 'Retained history',
targetRelPath: 'history.gsc',
scopeType: 'game',
+7 -1
View File
@@ -92,6 +92,7 @@ describe('multi-server blueprints', () => {
const { previewCreation } = await import('../../src/lib/server/fleet/create.js');
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
const artifact = createScript({
releaseVersion: '1.0.0',
name: 'Telemetry',
targetRelPath: 'telemetry.gsc',
scopeType: 'game',
@@ -107,7 +108,12 @@ describe('multi-server blueprints', () => {
'INSERT INTO server_blueprints (id,name,created_at,recipe) VALUES (?, ?, ?, ?)'
)
.run('legacy', 'Old favorite', new Date().toISOString(), JSON.stringify(recipe));
const current = uploadNewVersion(artifact.script.id, Buffer.from('current telemetry'));
const current = uploadNewVersion(
artifact.script.id,
Buffer.from('current telemetry'),
undefined,
'1.0.1'
);
db()
.prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?')
.run(current.id, artifact.script.id);
+6
View File
@@ -255,6 +255,7 @@ describe('drift scanning', () => {
const server = 't5_zombie_theater_1';
const relPath = kind === 'script' ? 'outdated.gsc' : 'outdated.dll';
const { script, version } = createScript({
releaseVersion: '1.0.0',
name: `outdated ${kind}`,
kind,
scopeType: 'game',
@@ -295,6 +296,7 @@ describe('drift scanning', () => {
const relPath = kind === 'script' ? 'managed.gsc' : 'managed.dll';
const content = Buffer.from('promoted content');
const { script, version } = createScript({
releaseVersion: '1.0.0',
name: `managed ${kind}`,
kind,
scopeType: 'game',
@@ -465,6 +467,7 @@ describe('drift scanning', () => {
const content = Buffer.from('main(){}\n');
const before = mode === 'identical' ? content : Buffer.from('old bytes');
const { script, version } = createScript({
releaseVersion: '1.0.0',
name: 'spawn fix',
kind: 'script',
scopeType: 'game',
@@ -543,6 +546,7 @@ describe('drift rebuild campaigns', () => {
await seedServerRegistry();
const server = 't5_zombie_theater_1';
const { script, version } = createScript({
releaseVersion: '1.0.0',
name: 'managed',
kind: 'script',
scopeType: 'game',
@@ -620,6 +624,7 @@ describe('drift rebuild campaigns', () => {
// off disk so drift's "missing" pass has something to find.
async function promoteAndOrphan(
createScript: (i: {
releaseVersion?: string;
name: string;
kind: 'script' | 'plugin' | 'baseline' | 'config';
scopeType?: 'game' | 'map';
@@ -638,6 +643,7 @@ async function promoteAndOrphan(
db: () => { prepare: (q: string) => { run: (...args: unknown[]) => unknown } }
): Promise<{ scriptId: number }> {
const created = createScript({
releaseVersion: '1.0.0',
name: 't5 hud',
kind: 'script',
scopeType: 'game',
+6 -1
View File
@@ -244,7 +244,12 @@ describe('GitHub DLL imports', () => {
await expect(importPluginRelease({ url, assetId: 999, name: 'Utilities' })).rejects.toThrow(
/no longer available/
);
const script = createScript({ name: 'Script', targetRelPath: 's.gsc', content });
const script = createScript({
releaseVersion: '1.0.0',
name: 'Script',
targetRelPath: 's.gsc',
content
});
await expect(
importPluginRelease({ url, assetId: 200, pluginId: script.script.id })
).rejects.toMatchObject({ status: 404 });
+1
View File
@@ -51,6 +51,7 @@ async function seedArtifacts() {
content: Buffer.from('set sv_voice "1"\n')
});
const script = createScript({
releaseVersion: '1.0.0',
name: 'Classic perks',
kind: 'script',
scopeType: 'game',
+1
View File
@@ -20,6 +20,7 @@ describe('scope impact preview', () => {
await import('../../src/lib/server/fleet/inventory.js');
const { GET } = await import('../../src/routes/api/scripts/[id]/scope/+server.js');
const script = createScript({
releaseVersion: '1.0.0',
name: 'Example',
scopeType: 'game',
scopeValue: 't5',
+1
View File
@@ -55,6 +55,7 @@ async function setup(capable = true) {
targetRelPath?: string
) {
const artifact = createScript({
releaseVersion: '1.0.0',
name,
kind,
targetRelPath,
+2 -1
View File
@@ -275,6 +275,7 @@ describe('asynchronous native launch preparation', () => {
kind === 'baseline'
? baseline
: createScript({
releaseVersion: '1.0.0',
name: `Launch ${kind}`,
kind,
targetRelPath:
@@ -299,7 +300,7 @@ describe('asynchronous native launch preparation', () => {
? 'set rcon_password "password"\nset sv_voice "0"\n'
: 'newly promoted file'
);
const latest = uploadNewVersion(script.id, content);
const latest = uploadNewVersion(script.id, content, undefined, '1.0.1');
db()
.prepare('UPDATE scripts SET current_version_id=? WHERE id=?')
.run(latest.id, script.id);
+19 -4
View File
@@ -188,6 +188,7 @@ describe('rebalance instance migrations', () => {
content: Buffer.from('set sv_voice "1"\n')
});
const script = createScript({
releaseVersion: '1.0.0',
name: 'Deployed script',
kind: 'script',
targetRelPath: 'preserved.gsc',
@@ -208,7 +209,9 @@ describe('rebalance instance migrations', () => {
await acknowledge(job.deploymentId);
const patched = uploadNewVersion(
script.script.id,
Buffer.from('confirmed patch after initial launch')
Buffer.from('confirmed patch after initial launch'),
undefined,
'1.0.1'
);
const { recordScript } = await import('../../src/lib/server/fleet/inventory.js');
recordScript(
@@ -221,7 +224,9 @@ describe('rebalance instance migrations', () => {
);
const newest = uploadNewVersion(
script.script.id,
Buffer.from('new promotion must not be installed')
Buffer.from('new promotion must not be installed'),
undefined,
'1.0.2'
);
db()
.prepare('UPDATE scripts SET current_version_id=? WHERE id=?')
@@ -3418,6 +3423,7 @@ describe('native provisioning cancellation and rollback', () => {
const { reportHostOperation } = await import('../../src/lib/server/hosts/index.js');
const { rollbackBeta } = await import('../../src/lib/server/fleet/beta.js');
const { script, version } = createScript({
releaseVersion: '1.0.0',
name: 'Broken beta',
kind: 'script',
targetRelPath: 'beta.gsc',
@@ -3774,6 +3780,7 @@ describe('cancelled native mutation bookkeeping', () => {
const { reportHostOperation, getHostOperation } =
await import('../../src/lib/server/hosts/index.js');
const { script, version } = createScript({
releaseVersion: '1.0.0',
name: 'Revised beta',
kind: 'script',
targetRelPath: 'revision.gsc',
@@ -3804,7 +3811,11 @@ describe('cancelled native mutation bookkeeping', () => {
// A dispatched activation must settle before revision submission.
await expect(
reviseBeta(betaId, 1, [
{ scriptId: script.id, content: Buffer.from('premature update') }
{
scriptId: script.id,
releaseVersion: '1.0.1',
content: Buffer.from('premature update')
}
])
).rejects.toThrow();
const activation = (await operationFor(restart.id))!;
@@ -3823,7 +3834,11 @@ describe('cancelled native mutation bookkeeping', () => {
}
await activate(original);
await reviseBeta(betaId, 1, [
{ scriptId: script.id, content: Buffer.from('revision two') }
{
scriptId: script.id,
releaseVersion: '1.0.1',
content: Buffer.from('revision two')
}
]);
await activate('revision one');
const rollback = await rollbackBeta(betaId);
+1
View File
@@ -165,6 +165,7 @@ describe('native disruption safety at dispatch', () => {
const pending = await suspendInspection();
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
const { script, version } = createScript({
releaseVersion: '1.0.0',
name: 'Race beta',
kind: 'script',
targetRelPath: 'beta.gsc',
+1
View File
@@ -29,6 +29,7 @@ beforeEach(async () => {
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
const { db } = await import('../../src/lib/server/db/index.js');
const { script, version } = createScript({
releaseVersion: '1.0.0',
name: 'HUD',
targetRelPath: 'hud.gsc',
content: Buffer.from('main(){}')
+2 -1
View File
@@ -34,6 +34,7 @@ async function promotion(kind: 'script' | 'plugin' | 'baseline', staged: boolean
? 'managed.dll'
: 'managed.gsc';
const { script, version: prior } = createScript({
releaseVersion: '1.0.0',
name: 'managed',
kind,
targetRelPath: relPath,
@@ -41,7 +42,7 @@ async function promotion(kind: 'script' | 'plugin' | 'baseline', staged: boolean
scopeValue: 't5',
content: before
});
const version = uploadNewVersion(script.id, after);
const version = uploadNewVersion(script.id, after, undefined, '1.0.1');
db()
.prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?')
.run(version.id, script.id);
+7
View File
@@ -63,6 +63,7 @@ describe('editing promoted script scopes', () => {
const { db } = await import('../../src/lib/server/db/index.js');
const scopeValue = scopeType === 'game' ? 't5' : 'zombie_theater';
const { script, version } = createScript({
releaseVersion: '1.0.0',
name: 'restore scope',
targetRelPath: 'restore.gsc',
scopeType,
@@ -135,6 +136,7 @@ describe('editing promoted script scopes', () => {
const { openBeta } = await import('../../src/lib/server/fleet/beta.js');
const { db } = await import('../../src/lib/server/db/index.js');
const { script, version } = createScript({
releaseVersion: '1.0.0',
name: 'active scope',
targetRelPath: 'active.gsc',
scopeType: 'game',
@@ -179,6 +181,7 @@ describe('promote creates a deployment', () => {
// A game-scoped script matches both fixture servers.
const { script } = createScript({
releaseVersion: '1.0.0',
name: 'hud tweaks',
targetRelPath: 'hud.gsc',
scopeType: 'game',
@@ -268,6 +271,7 @@ describe('rollback beta on ready-promote marks version test-failed', () => {
const { db } = await import('../../src/lib/server/db/index.js');
const { script } = createScript({
releaseVersion: '1.0.0',
name: 'flag experiment',
targetRelPath: 'flag.gsc',
scopeType: 'map',
@@ -310,6 +314,7 @@ describe('rollback promote deployment', () => {
const { db } = await import('../../src/lib/server/db/index.js');
const { script } = createScript({
releaseVersion: '1.0.0',
name: 'roll me back',
targetRelPath: 'rb.gsc',
scopeType: 'game',
@@ -379,6 +384,7 @@ describe('multi-script beta', () => {
// Two scripts, both scope=game=t5 so they both land on both fixture servers.
const a = createScript({
releaseVersion: '1.0.0',
name: 'hud',
targetRelPath: 'hud.gsc',
scopeType: 'game',
@@ -386,6 +392,7 @@ describe('multi-script beta', () => {
content: Buffer.from('hud(){}\n')
});
const b = createScript({
releaseVersion: '1.0.0',
name: 'flag',
targetRelPath: 'flag.gsc',
scopeType: 'game',
+12 -8
View File
@@ -10,8 +10,9 @@ beforeEach(async () => {
afterEach(async () => {
await fixture.cleanup();
});
function request(content: string, reuse = false) {
function request(content: string, reuse = false, releaseVersion = '1.0.0') {
const form = new FormData();
form.append('releaseVersion', releaseVersion);
form.append('content', new File([content], 'hud.gsc'));
if (reuse) form.append('reuseExisting', 'true');
return new Request('http://localhost/api/scripts/inspect', { method: 'POST', body: form });
@@ -21,6 +22,7 @@ async function seed() {
return {
...mod,
...mod.createScript({
releaseVersion: '1.0.0',
name: 'HUD',
targetRelPath: 'hud.gsc',
scopeType: 'game',
@@ -33,7 +35,7 @@ async function seed() {
describe('script upload review', () => {
it('inspects content against all versions without creating records or including plugins', async () => {
const { script, version, uploadNewVersion, createScript, listVersions } = await seed();
uploadNewVersion(script.id, Buffer.from('changed(){}'));
uploadNewVersion(script.id, Buffer.from('changed(){}'), undefined, '1.0.1');
createScript({
name: 'Plugin',
targetRelPath: 'plugin.dll',
@@ -44,17 +46,19 @@ describe('script upload review', () => {
const res = await POST({ request: request('main(){}') });
expect(await res.json()).toEqual({
sha256: version.sha256,
duplicates: [{ scriptId: script.id, versionId: version.id, version: 1 }]
duplicates: [
{ scriptId: script.id, versionId: version.id, version: 1, release_version: '1.0.0' }
]
});
expect(listVersions(script.id)).toHaveLength(2);
expect((await POST({ request: request('') })).status).toBe(400);
});
it('reuses identical historical content and creates changed versions while preserving scope', async () => {
const { script, version, uploadNewVersion, listVersions, getScript } = await seed();
uploadNewVersion(script.id, Buffer.from('changed(){}'));
uploadNewVersion(script.id, Buffer.from('changed(){}'), undefined, '1.0.2');
const { POST } = await import('../../src/routes/api/scripts/[id]/versions/+server.js');
const invoke = (content: string) =>
POST({ params: { id: String(script.id) }, request: request(content, true) });
const invoke = (content: string, release = '1.0.0') =>
POST({ params: { id: String(script.id) }, request: request(content, true, release) });
const res = await invoke('main(){}');
expect(await res.json()).toMatchObject({
ok: true,
@@ -62,7 +66,7 @@ describe('script upload review', () => {
version: { id: version.id, version: 1 }
});
expect(listVersions(script.id)).toHaveLength(2);
const added = await invoke('third(){}');
const added = await invoke('third(){}', '1.1.0');
expect(await added.json()).toMatchObject({ ok: true, version: { version: 3 } });
expect(listVersions(script.id)).toHaveLength(3);
expect(getScript(script.id)).toMatchObject({ scope_type: 'game', scope_value: 't5' });
@@ -90,7 +94,7 @@ describe('script upload review', () => {
});
it('lists promoted and latest version metadata separately without file contents', async () => {
const { script, version, uploadNewVersion } = await seed();
const latest = uploadNewVersion(script.id, Buffer.from('changed(){}'));
const latest = uploadNewVersion(script.id, Buffer.from('changed(){}'), undefined, '1.0.3');
const { db } = await import('../../src/lib/server/db/index.js');
db()
.prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?')
+140
View File
@@ -0,0 +1,140 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { newFixture, type Fixture } from '../helpers/fixture.js';
import {
manifestVersion,
parseScriptVersions,
scriptVersionError
} from '../../src/lib/script-version.js';
import { artifactVersionLabel } from '../../src/lib/artifact-version.js';
it('validates numeric release versions and exact manifest filenames', () => {
for (const value of [
undefined,
null,
'',
' ',
'v1.0.0',
'01.0.0',
'1.0',
'1.0.0-beta',
'1.0.0\nextra',
'1.'.repeat(50)
])
expect(scriptVersionError(value)).not.toBe('');
for (const value of ['0.0.0', '1.20.3', ' 1.2.0 ']) expect(scriptVersionError(value)).toBe('');
expect(manifestVersion({}, 'constructor')).toBe('');
expect(parseScriptVersions('{"hud.gsc":"1.2.0","HUD.gsc":"2.0.0","t5-plugin":"tag"}')).toEqual({
'hud.gsc': '1.2.0',
'HUD.gsc': '2.0.0'
});
for (const text of [
'oops',
'[]',
'null',
'{}',
'{"hud.gsc":1}',
'{"hud.gsc":"1"}',
'{"path/hud.gsc":"1.0.0"}'
])
expect(() => parseScriptVersions(text)).toThrow();
});
it('distinguishes historical script uploads from publisher versions', () => {
expect(artifactVersionLabel({ version: 3 }, 'script')).toBe('Unversioned (upload 3)');
expect(artifactVersionLabel({ version: 3, release_version: '1.2.0' }, 'script')).toBe(
'1.2.0 (upload 3)'
);
expect(artifactVersionLabel({ version: 3 }, 'config')).toBe('v3');
});
let fixture: Fixture;
beforeEach(async () => {
vi.resetModules();
fixture = await newFixture();
});
afterEach(async () => {
await fixture.cleanup();
});
const script = { name: 'HUD', targetRelPath: 'hud.gsc', content: Buffer.from('main(){}') };
describe('mandatory script releases', () => {
it('rejects missing and invalid versions without creating records', async () => {
const { createScript, uploadNewVersion, listScripts, listVersions } =
await import('../../src/lib/server/fleet/scripts.js');
for (const releaseVersion of [undefined, '', 'v1.0.0', '1.0', '1.0.0-rc1']) {
expect(() => createScript({ ...script, releaseVersion })).toThrow(/release version/);
}
expect(listScripts()).toHaveLength(0);
const saved = createScript({ ...script, releaseVersion: ' 1.0.0 ' });
expect(saved.version.release_version).toBe('1.0.0');
expect(() => uploadNewVersion(saved.script.id, script.content)).toThrow(/required/);
expect(listVersions(saved.script.id)).toHaveLength(1);
});
it('reuses identical releases, rejects changed contents and accepts older versions', async () => {
const { createScript, uploadNewVersion, listVersions } =
await import('../../src/lib/server/fleet/scripts.js');
const saved = createScript({ ...script, releaseVersion: '2.0.0' });
expect(uploadNewVersion(saved.script.id, script.content, 'retry', '2.0.0')).toEqual(
saved.version
);
expect(() =>
uploadNewVersion(saved.script.id, Buffer.from('changed'), undefined, '2.0.0')
).toThrow(/Bump/);
const older = uploadNewVersion(
saved.script.id,
Buffer.from('old release'),
undefined,
'1.0.0'
);
expect(older).toMatchObject({ version: 2, release_version: '1.0.0' });
expect(listVersions(saved.script.id)).toHaveLength(2);
expect(
createScript({ ...script, name: 'Other', releaseVersion: '2.0.0' }).version
.release_version
).toBe('2.0.0');
});
it('preserves legacy content and does not infer a release from matching bytes', async () => {
const { createScript, uploadNewVersion, getVersion, getVersionContent } =
await import('../../src/lib/server/fleet/scripts.js');
const { db } = await import('../../src/lib/server/db/index.js');
const saved = createScript({ ...script, releaseVersion: '1.0.0' });
db()
.prepare('UPDATE script_versions SET release_version = NULL WHERE id = ?')
.run(saved.version.id);
const next = uploadNewVersion(saved.script.id, script.content, undefined, '1.0.0');
expect(next).toMatchObject({ version: 2, release_version: '1.0.0' });
expect(getVersion(saved.version.id)!.release_version).toBeNull();
expect(getVersionContent(saved.version.id)).toEqual(script.content);
});
it('enforces the API contract even when reuseExisting is requested', async () => {
const create = await import('../../src/routes/api/scripts/+server.js');
const upload = await import('../../src/routes/api/scripts/[id]/versions/+server.js');
const { listScripts, listVersions } = await import('../../src/lib/server/fleet/scripts.js');
const url = new URL('http://test/api/scripts');
const form = new FormData();
form.set('name', 'HUD');
form.set('content', new File(['main(){}'], 'hud.gsc'));
const request = () => new Request(url, { method: 'POST', body: form });
expect((await create.POST({ url, request: request() })).status).toBe(400);
expect(listScripts()).toHaveLength(0);
form.set('releaseVersion', '1.0.0');
const saved = await (await create.POST({ url, request: request() })).json();
const params = { id: String(saved.script.id) };
form.set('reuseExisting', 'true');
form.delete('releaseVersion');
expect((await upload.POST({ params, request: request() })).status).toBe(400);
form.set('releaseVersion', '1.0.0');
expect(await (await upload.POST({ params, request: request() })).json()).toMatchObject({
reused: true,
version: { id: saved.versionId, release_version: '1.0.0' }
});
form.set('content', new File(['different'], 'hud.gsc'));
expect((await upload.POST({ params, request: request() })).status).toBe(409);
expect(listVersions(saved.script.id)).toHaveLength(1);
form.set('releaseVersion', '0.9.0');
expect((await upload.POST({ params, request: request() })).status).toBe(200);
});
});
+3 -3
View File
@@ -68,7 +68,7 @@ describe('server detail data', () => {
const { script, version } = createScript({
name: 'Plugin',
kind: 'plugin',
releaseVersion: 'v1.0.0',
releaseVersion: '1.0.0',
targetRelPath: 'native.bin',
content: Buffer.from('plugin')
});
@@ -96,7 +96,7 @@ describe('server detail data', () => {
).toMatchObject({
artifactId: script.id,
version: version.version,
releaseVersion: 'v1.0.0',
releaseVersion: '1.0.0',
sha256: version.sha256
});
});
@@ -113,7 +113,7 @@ describe('server detail data', () => {
const { version } = createScript({
name: 'Plugin',
kind: 'plugin',
releaseVersion: 'v1.0.0',
releaseVersion: '1.0.0',
targetRelPath: path,
content: Buffer.from('plugin')
});
+2 -1
View File
@@ -60,6 +60,7 @@ async function queueChange(source: Source, targets = [servers[0]]) {
const { recordScript, getServerScript } =
await import('../../src/lib/server/fleet/inventory.js');
const original = createScript({
releaseVersion: '1.0.0',
name: 'Staged script',
kind: 'script',
targetRelPath: relPath,
@@ -67,7 +68,7 @@ async function queueChange(source: Source, targets = [servers[0]]) {
scopeValue: 't5',
content: before
});
const replacement = uploadNewVersion(original.script.id, after);
const replacement = uploadNewVersion(original.script.id, after, undefined, '1.0.1');
for (const server of targets) {
await writeFile(destination(server), before);
recordScript(
+9
View File
@@ -108,3 +108,12 @@ it('explains pending automatic downloads and keeps copy errors visible even for
expect(body).toContain('Host report needs review');
expect(body).not.toContain('Ready hosts');
});
it('shows script release versions and explicitly labels historical uploads', () => {
const current = { id: 1, version: 1, release_version: null };
const body = render(LibraryVersions, {
props: { kind: 'script', current, latest: { id: 2, version: 2, release_version: '1.2.0' } }
}).body;
expect(body).toContain('Unversioned (upload 1)');
expect(body).toContain('1.2.0 (upload 2)');
});
+1
View File
@@ -621,6 +621,7 @@ describe('Watchdog detection and ownership', () => {
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
const { openBeta } = await import('../../src/lib/server/fleet/beta.js');
const { script, version } = createScript({
releaseVersion: '1.0.0',
name: 'beta',
content: Buffer.from('experimental'),
targetRelPath: 'beta.gsc'