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
+7 -1
View File
@@ -429,7 +429,13 @@ recent evidence under `test`.
copied into a beta; later edits do not change an active plan.
`POST /api/betas/:id/revisions` accepts multipart `expectedRevision`, optional
`notes`, and replacement uploads named `file:<scriptId>`. A stale revision is
`notes`, and replacement uploads named `file:<scriptId>`. Each script replacement
also requires `release:<scriptId>` in `MAJOR.MINOR.PATCH` form. The update dialog
accepts manual versions or `versions.json` to fill empty fields by exact filename.
A changed script cannot reuse a previous release version. Missing or invalid
versions reject the entire update without modifying beta history or deployments.
Plugin release tags remain optional. See [script release versions](getting-started.md#script-release-versions).
A stale revision is
rejected. `GET /api/betas/:id` includes the current `revision` and `revisions`
history; `GET /api/betas/:id/revisions/:revision/files/:scriptId` downloads an
archived file (or the original pinned file before its first update).
+29
View File
@@ -108,6 +108,35 @@ servers. Disruptive operations normally wait for a fresh empty lobby or a
verified stopped container. A rebuild removes the instance's writable files.
See [Provisioning](provisioning.md) and [Collections](collections.md) for details.
## Script release versions
Every new script upload needs a release version in `MAJOR.MINOR.PATCH` form,
for example `1.2.0`. Enter a version for each file in the upload drawer, script
page, or beta replacement dialog. Versions belong to individual scripts; a batch
can contain different versions.
To fill empty fields automatically, choose `versions.json` from the same source
checkout as your scripts. Cherry matches exact, case-sensitive filenames (such as
`tws_t5_hud.gsc`) and leaves entered values intact. Review the values before
uploading. A manifest identifies source releases; it does not verify file contents.
Changing a script requires a new release version. Reuploading identical bytes with
the same version reuses the saved release; different bytes with that version are
rejected. Older release versions can be imported. Cherry keeps its upload number
separately, displaying labels such as `1.2.0 (upload 7)`.
Historical scripts without a release label display `Unversioned (upload N)` and
remain available for testing, promotion, and rollback. Cherry does not infer old
versions from today's manifest or rewrite historical records. Uploading the same
bytes with an explicit version creates a separate labeled record when needed.
Plugins retain optional publisher tags; configurations retain automatic upload
numbers.
API clients must include multipart `releaseVersion` when creating a script with
`POST /api/scripts` or uploading with `POST /api/scripts/:id/versions`. Missing or
invalid versions return HTTP 400; conflicting contents return HTTP 409. The
version upload response reports `reused: true` for an existing identical release.
## Connect game telemetry
Configure your game reporter or plugin to send `EC_TELEMETRY_TOKEN` in an
+4 -1
View File
@@ -1,9 +1,12 @@
/** Keep the upload number visible: two builds may share a publisher's release tag. */
export function artifactVersionLabel(
version: { version: number; release_version?: string | null } | null | undefined
version: { version: number; release_version?: string | null } | null | undefined,
kind?: string
): string {
if (!version) return '—';
return version.release_version
? `${version.release_version} (upload ${version.version})`
: kind === 'script'
? `Unversioned (upload ${version.version})`
: `v${version.version}`;
}
+1
View File
@@ -4,6 +4,7 @@ import { gameTitle } from './ui/server-labels.js';
export interface LibraryVersion {
id: number;
version: number;
release_version?: string | null;
sha256: string;
size: number;
state: 'untested' | 'tested' | 'test-failed';
+33
View File
@@ -0,0 +1,33 @@
/** TWS source releases use three numeric components, without tags or suffixes. */
export function scriptVersionError(value: unknown): string {
if (typeof value !== 'string' || !value.trim()) return 'A script release version is required.';
if (value.length > 80 || !/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.test(value.trim()))
return 'Use MAJOR.MINOR.PATCH for the script release version, such as 1.2.0.';
return '';
}
/** Ignore non-script components (for example native plugins) in a TWS manifest. */
export function parseScriptVersions(text: string): Record<string, string> {
let data: unknown;
try {
data = JSON.parse(text);
} catch {
throw new Error('Choose a valid versions.json file.');
}
if (!data || typeof data !== 'object' || Array.isArray(data))
throw new Error('versions.json must map script filenames to release versions.');
const entries = Object.entries(data).filter(([name]) => name.endsWith('.gsc'));
if (!entries.length) throw new Error('versions.json contains no .gsc filenames.');
for (const [name, value] of entries) {
if (name.includes('/') || name.includes('\\'))
throw new Error(`Use an exact filename in versions.json: ${name}`);
const error = scriptVersionError(value);
if (error) throw new Error(`${name}: ${error}`);
}
return Object.fromEntries(entries.map(([name, value]) => [name, (value as string).trim()]));
}
/** Uploaded filenames must never resolve inherited object properties. */
export function manifestVersion(versions: Record<string, string>, filename: string): string {
return Object.hasOwn(versions, filename) ? versions[filename] : '';
}
+25 -3
View File
@@ -1,3 +1,4 @@
import { scriptVersionError } from '../../script-version.js';
import { putFileBlob, blobContent } from '../db/file-blobs.js';
import { createHash } from 'node:crypto';
import { db } from '../db/index.js';
@@ -73,6 +74,7 @@ export class ScriptError extends Error {
| 'baseline-exists'
| 'empty-overlay'
| 'invalid-release-version'
| 'release-version-conflict'
| 'too-large'
) {
super(message);
@@ -95,6 +97,14 @@ function normalizeReleaseVersion(value: string | null | undefined): string | nul
return value.trim() || null;
}
function releaseForKind(kind: ArtifactKind, value: string | null | undefined): string | null {
if (kind === 'script') {
const error = scriptVersionError(value);
if (error) throw new ScriptError(error, 'invalid-release-version');
}
return normalizeReleaseVersion(value);
}
export function getScript(id: number): ScriptRow | undefined {
return db().prepare('SELECT * FROM scripts WHERE id = ?').get(id) as ScriptRow | undefined;
}
@@ -273,7 +283,7 @@ export function createScript(input: CreateScriptInput): {
throw new ScriptError(`script "${input.name}" already exists`, 'name-taken');
const sha = sha256(input.content);
const releaseVersion = normalizeReleaseVersion(input.releaseVersion);
const releaseVersion = releaseForKind(kind, input.releaseVersion);
const size = input.content.byteLength;
const insertScript = db().prepare(
@@ -335,10 +345,21 @@ export function uploadNewVersion(
}
}
const release = releaseForKind(script.kind, releaseVersion);
const sha = sha256(content);
return db().transaction(() => {
if (script.kind === 'script') {
const existing = listVersions(scriptId).filter((v) => v.release_version === release);
if (existing.some((v) => v.sha256 !== sha)) {
throw new ScriptError(
`Release ${release} already has different contents. Bump the script release version.`,
'release-version-conflict'
);
}
if (existing.length) return existing[0];
}
const latest = getLatestVersion(scriptId);
const nextVersion = (latest?.version ?? 0) + 1;
const sha = sha256(content);
const release = normalizeReleaseVersion(releaseVersion);
const res = db()
.prepare(
@@ -356,6 +377,7 @@ export function uploadNewVersion(
release
);
return getVersion(Number(res.lastInsertRowid))!;
})();
}
function validateScope(scopeType: ScopeType | null, scopeValue: string | null): void {
+42 -9
View File
@@ -1,4 +1,6 @@
<script lang="ts">
import { manifestVersion, scriptVersionError } from '$lib/script-version.js';
import ScriptVersionManifest from './ScriptVersionManifest.svelte';
import { serverTitle } from '$ui/server-labels.js';
import RelativeTime from '$ui/components/RelativeTime.svelte';
import { page } from '$app/stores';
@@ -69,7 +71,13 @@
let versions = $state<Version[]>([]);
let matchedServers = $state<string[]>([]);
let installedVersions = $state<
Array<{ server: string; version: number; versionId: number; recordedAt: string }>
Array<{
server: string;
version: number;
versionId: number;
release_version: string | null;
recordedAt: string;
}>
>([]);
interface ScopeImpact {
before: string[];
@@ -103,6 +111,7 @@
let versionFile = $state<File | null>(null);
let versionNotes = $state('');
let releaseVersion = $state('');
let versionManifest = $state<Record<string, string>>({});
let uploadingVersion = $state(false);
let scopeDialog = $state<HTMLDialogElement | null>(null);
@@ -244,6 +253,7 @@
function openUploadModal(): void {
if (!editable || runningBetaId !== null) return;
releaseVersion = '';
versionManifest = {};
versionFile = null;
versionNotes = '';
flash = null;
@@ -271,16 +281,23 @@
function pickVersionFile(e: Event): void {
const t = e.target as HTMLInputElement;
versionFile = t.files?.[0] ?? null;
if (!releaseVersion.trim() && versionFile)
releaseVersion = manifestVersion(versionManifest, versionFile.name);
}
async function uploadVersion(e: Event): Promise<void> {
e.preventDefault();
if (!editable || !versionFile || uploadingVersion) return;
if (family === 'script' && scriptVersionError(releaseVersion)) {
flash = scriptVersionError(releaseVersion);
return;
}
uploadingVersion = true;
flash = null;
const form = new FormData();
form.append('content', versionFile);
if (family === 'plugin') form.append('releaseVersion', releaseVersion.trim());
if (family === 'plugin' || family === 'script')
form.append('releaseVersion', releaseVersion.trim());
if (versionNotes.trim()) form.append('notes', versionNotes.trim());
try {
const res = await fetch(`/api/scripts/${id}/versions`, { method: 'POST', body: form });
@@ -419,7 +436,7 @@
{#if latestVersion}<Card padding="lg"
><div class="release-summary">
<div>
<h2>Latest {artifactVersionLabel(latestVersion)}</h2>
<h2>Latest {artifactVersionLabel(latestVersion, family)}</h2>
<StatusBadge
state={latestVersion.id === a.current_version_id
? 'promoted'
@@ -517,7 +534,7 @@
<span class="k">Promoted version</span>
<span class="v mono">
{#if currentVersion}
{artifactVersionLabel(currentVersion)} · {currentVersion.sha256.slice(
{artifactVersionLabel(currentVersion, family)} · {currentVersion.sha256.slice(
0,
8
)}
@@ -541,7 +558,9 @@
<a href={'/servers/' + encodeURIComponent(installed.server)}
>{serverTitle(installed.server)}</a
>
· v{installed.version} · <RelativeTime value={installed.recordedAt} />
· {artifactVersionLabel(installed, family)} · <RelativeTime
value={installed.recordedAt}
/>
</li>{/each}
</ul>
</details>{:else if family !== 'config'}<p class="dim">
@@ -575,7 +594,7 @@
<div class="version-list">
{#each versions.slice(0, limit) as v (v.id)}
<div class="version-row" class:current={a.current_version_id === v.id}>
<span class="v-num">{artifactVersionLabel(v)}</span>
<span class="v-num">{artifactVersionLabel(v, family)}</span>
<span class="v-notes"
>{v.notes ?? ''}
{#if v.source}<a
@@ -750,14 +769,28 @@
disabled={uploadingVersion}
/>
</Field>
{#if family === 'plugin'}
{#if family === 'script'}
<ScriptVersionManifest
disabled={uploadingVersion}
onload={(versions) => {
versionManifest = versions;
if (versionFile && !releaseVersion.trim())
releaseVersion = manifestVersion(versions, versionFile.name);
}}
/>
{/if}
{#if family === 'plugin' || family === 'script'}
<Field
label="Release version"
hint="Optional. Use the publisher’s version or release tag, such as v3.1.0."
hint={family === 'script'
? 'Required. Use MAJOR.MINOR.PATCH, such as 1.2.0.'
: 'Optional. Use the publisher’s version or release tag, such as v3.1.0.'}
>
<Input
bind:value={releaseVersion}
placeholder="v3.1.0"
placeholder={family === 'script' ? '1.2.0' : 'v3.1.0'}
required={family === 'script'}
maxlength={80}
disabled={uploadingVersion}
/>
</Field>
@@ -1,4 +1,8 @@
<script lang="ts">
import { manifestVersion, scriptVersionError } from '$lib/script-version.js';
import ScriptVersionManifest from './ScriptVersionManifest.svelte';
import Field from './primitives/Field.svelte';
import Input from './primitives/Input.svelte';
import Dialog from './primitives/Dialog.svelte';
import Button from './primitives/Button.svelte';
import DiffViewer from './DiffViewer.svelte';
@@ -31,19 +35,38 @@
} = $props();
interface Replacement {
file: File;
releaseVersion: string;
sha: string;
before: string | null;
after: string | null;
}
let manifest = $state<Record<string, string>>({});
let replacements = $state<Record<number, Replacement>>({});
let notes = $state(''),
error = $state(''),
reviewing = $state(false),
busy = $state(false);
const versionsValid = $derived(
files.every(
(entry) =>
!replacements[entry.id] ||
entry.kind !== 'script' ||
!scriptVersionError(replacements[entry.id].releaseVersion)
)
);
function applyManifest(versions: Record<string, string>) {
manifest = versions;
for (const entry of files) {
const replacement = replacements[entry.id];
if (entry.kind === 'script' && replacement && !replacement.releaseVersion.trim())
replacement.releaseVersion = manifestVersion(manifest, replacement.file.name);
}
}
const count = $derived(Object.keys(replacements).length);
$effect(() => {
if (open) {
replacements = {};
manifest = {};
notes = '';
error = '';
reviewing = false;
@@ -90,7 +113,13 @@
after = new TextDecoder().decode(bytes);
}
}
replacements[entry.id] = { file, sha, before, after };
replacements[entry.id] = {
file,
sha,
before,
after,
releaseVersion: entry.kind === 'script' ? manifestVersion(manifest, file.name) : ''
};
} catch (err) {
error = (err as Error).message;
} finally {
@@ -98,14 +127,17 @@
}
}
async function deploy() {
if (!versionsValid) return;
busy = true;
error = '';
try {
const form = new FormData();
form.set('expectedRevision', String(revision));
form.set('notes', notes);
for (const [scriptId, replacement] of Object.entries(replacements))
for (const [scriptId, replacement] of Object.entries(replacements)) {
form.set(`file:${scriptId}`, replacement.file);
form.set(`release:${scriptId}`, replacement.releaseVersion.trim());
}
const response = await fetch(`/api/betas/${betaId}/revisions`, {
method: 'POST',
body: form
@@ -132,12 +164,15 @@
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
{#if !reviewing}
<p>Choose replacements for the files you changed.</p>
{#if files.some((entry) => entry.kind === 'script')}
<ScriptVersionManifest disabled={busy} onload={applyManifest} />
{/if}
<div class="files">
{#each files as entry}
<label
><strong>{entry.name} · {artifactVersionLabel(entry.version)}</strong><code
>{entry.target_rel_path}</code
>
><strong
>{entry.name} · {artifactVersionLabel(entry.version, entry.kind)}</strong
><code>{entry.target_rel_path}</code>
<input
type="file"
disabled={busy}
@@ -145,6 +180,26 @@
/>
{#if replacements[entry.id]}<span>Replacement ready for review</span>{/if}
</label>
{#if replacements[entry.id] && (entry.kind === 'script' || entry.kind === 'plugin')}
<Field
label={`${entry.name} release version`}
hint={entry.kind === 'script'
? 'Required. Use MAJOR.MINOR.PATCH, such as 1.2.0.'
: 'Optional publisher release tag.'}
>
<Input
bind:value={replacements[entry.id].releaseVersion}
required={entry.kind === 'script'}
maxlength={80}
disabled={busy}
/>
</Field>
{#if entry.kind === 'script' && scriptVersionError(replacements[entry.id].releaseVersion)}
<p class="ui-error">
{scriptVersionError(replacements[entry.id].releaseVersion)}
</p>
{/if}
{/if}
{/each}
</div>
<label class="notes"
@@ -177,6 +232,9 @@
{@const replacement = replacements[entry.id]}
<section>
<h3>{entry.name}</h3>
{#if replacement.releaseVersion}<p>
Release {entry.version?.release_version ?? 'Unversioned'} → {replacement.releaseVersion.trim()}
</p>{/if}
<p>
<code>{entry.target_rel_path}</code> ← {replacement.file.name} ({replacement.file.size.toLocaleString()}
bytes)
@@ -204,7 +262,7 @@
{:else}<Button
variant="primary"
onclick={() => (reviewing = true)}
disabled={busy || !count}>Review update</Button
disabled={busy || !count || !versionsValid}>Review update</Button
>{/if}
{/snippet}
</Dialog>
+9 -3
View File
@@ -10,15 +10,21 @@
let {
current,
latest,
kind,
betaId = null
}: { current: Version | null; latest: Version | null; betaId?: number | null } = $props();
}: {
current: Version | null;
latest: Version | null;
betaId?: number | null;
kind?: string;
} = $props();
const newer = $derived(latest && latest.id !== current?.id ? latest : null);
const ambiguous = $derived(
!!newer?.release_version && newer.release_version === current?.release_version
);
function label(version: Version) {
return ambiguous
? artifactVersionLabel(version)
return kind === 'script' || ambiguous
? artifactVersionLabel(version, kind)
: version.release_version || `v${version.version}`;
}
const state = $derived(
@@ -1,4 +1,7 @@
<script lang="ts">
import { manifestVersion, scriptVersionError } from '$lib/script-version.js';
import { artifactVersionLabel } from '$lib/artifact-version.js';
import ScriptVersionManifest from './ScriptVersionManifest.svelte';
import Input from '$ui/components/primitives/Input.svelte';
import Select from '$ui/components/primitives/Select.svelte';
import Field from '$ui/components/primitives/Field.svelte';
@@ -20,23 +23,34 @@
scriptId: number;
versionId: number;
version: number;
release_version: string | null;
}
interface Upload {
key: number;
file: File;
target: number | 'new' | '';
name: string;
releaseVersion: string;
duplicates: Duplicate[];
inspecting: boolean;
inspected: boolean;
error: string;
result: (BetaSelection & { version: number; reused: boolean }) | null;
result:
(BetaSelection & { version: number; release_version: string; reused: boolean }) | null;
}
let dialog: HTMLDialogElement;
let input: HTMLInputElement;
let queue = $state<Upload[]>([]);
let busy = $state(false);
let notes = $state('');
let manifest = $state<Record<string, string>>({});
function applyManifest(versions: Record<string, string>) {
manifest = versions;
for (const item of queue) {
if (!item.result && !item.releaseVersion.trim())
item.releaseVersion = manifestVersion(manifest, item.file.name);
}
}
let scopeType = $state<'none' | 'game' | 'map'>('none');
let scopeGame = $state('');
let scopeMap = $state('');
@@ -80,6 +94,7 @@
// only when there is no pending file to lose.
if (!pending.length) {
queue = [];
manifest = {};
notes = '';
message = '';
preferredTarget = scriptId ?? null;
@@ -121,6 +136,7 @@
preferredTarget ??
(matches.length === 1 ? matches[0].id : matches.length > 1 ? '' : 'new'),
name: file.name.replace(/\.[^.]+$/, ''),
releaseVersion: manifestVersion(manifest, file.name),
duplicates: [],
inspecting: true,
inspected: false,
@@ -153,9 +169,13 @@
}
}
function duplicate(item: Upload) {
return item.duplicates.find((d) => d.scriptId === item.target);
return item.duplicates.find(
(d) => d.scriptId === item.target && d.release_version === item.releaseVersion.trim()
);
}
function validation(item: Upload): string {
const versionError = scriptVersionError(item.releaseVersion);
if (versionError) return versionError;
if (item.target === '')
return 'Several scripts use this filename. Choose which script to update.';
if (item.target === 'new') {
@@ -194,6 +214,7 @@
const form = new FormData();
form.append('content', item.file);
form.append('notes', notes.trim());
form.append('releaseVersion', item.releaseVersion.trim());
const creating = item.target === 'new';
if (creating) {
form.append('name', item.name.trim());
@@ -211,6 +232,7 @@
scriptId: creating ? body.script.id : (item.target as number),
versionId: creating ? body.versionId : body.version.id,
version: creating ? 1 : body.version.version,
release_version: item.releaseVersion.trim(),
reused: !!body.reused
};
} catch (e) {
@@ -286,6 +308,7 @@
}}
/>
</div>
<ScriptVersionManifest disabled={busy || inspecting} onload={applyManifest} />
<div class="queue" aria-live="polite">
{#each queue as item (item.key)}
<section class="upload-item" aria-label={item.file.name}>
@@ -304,11 +327,26 @@
</div>
{#if item.result}
<p class="success">
{item.result.reused ? 'Already uploaded' : 'Uploaded'} as v{item.result
.version} ·
{item.result.reused ? 'Already uploaded' : 'Uploaded'} as {artifactVersionLabel(
item.result,
'script'
)} ·
<a href="/scripts/{item.result.scriptId}">View script →</a>
</p>
{:else}
<Field
label="Release version"
hint="Required. Use MAJOR.MINOR.PATCH, such as 1.2.0."
>
<Input
bind:value={item.releaseVersion}
required
maxlength={80}
placeholder="1.2.0"
disabled={busy}
oninput={() => (item.error = '')}
/>
</Field>
<Field label="Destination">
<Select
bind:value={item.target}
@@ -336,8 +374,10 @@
</p>{/if}
{#if item.inspecting}<p class="note">Checking file contents…</p>
{:else if duplicate(item)}<p class="success">
Already uploaded as v{duplicate(item)!.version}. We’ll reuse that
version.
Already uploaded as {artifactVersionLabel(
duplicate(item),
'script'
)}. We’ll reuse that version.
</p>
{:else if item.target !== 'new' && item.target !== ''}<p class="note">
New version for {scripts.find((s) => s.id === item.target)?.name}
@@ -0,0 +1,44 @@
<script lang="ts">
import { parseScriptVersions } from '$lib/script-version.js';
import Field from './primitives/Field.svelte';
import Input from './primitives/Input.svelte';
let {
disabled = false,
onload
}: {
disabled?: boolean;
onload: (versions: Record<string, string>) => void;
} = $props();
let message = $state('');
let error = $state('');
let loading = $state(false);
async function choose(event: Event) {
const input = event.currentTarget as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) return;
loading = true;
message = error = '';
try {
if (file.size > 1024 * 1024)
throw new Error('versions.json must be smaller than 1 MiB.');
const versions = parseScriptVersions(await file.text());
onload(versions);
message = `Loaded ${Object.keys(versions).length} script versions. Review the matched values below.`;
} catch (e) {
error = (e as Error).message;
} finally {
loading = false;
}
}
</script>
<Field
label="Version manifest (optional)"
hint="Choose versions.json from the same source checkout as your files. Matches exact filenames; fills empty release version fields."
>
<Input type="file" accept=".json" disabled={disabled || loading} onchange={choose} />
</Field>
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
{#if message}<p role="status">{message}</p>{/if}
@@ -39,7 +39,14 @@ export async function POST({ params, request }: { params: { id: string }; reques
if (isHttpError(error)) throw error;
return json(
{ ok: false, error: (error as Error).message },
{ status: error instanceof BetaError || error instanceof ScriptError ? 409 : 400 }
{
status:
error instanceof ScriptError && error.kind === 'invalid-release-version'
? 400
: error instanceof BetaError || error instanceof ScriptError
? 409
: 400
}
);
}
}
+4 -1
View File
@@ -89,7 +89,10 @@ export async function POST({ request, url }: { request: Request; url: URL }) {
return json({ ok: true, script, versionId: version.id });
} catch (err) {
if (err instanceof ScriptError) {
return json({ ok: false, error: err.message, kind: err.kind }, { status: 409 });
return json(
{ ok: false, error: err.message, kind: err.kind },
{ status: err.kind === 'invalid-release-version' ? 400 : 409 }
);
}
throw err;
}
+1 -1
View File
@@ -50,7 +50,7 @@ export async function GET({ params }: { params: { id: string } }) {
script.kind === 'script' || script.kind === 'plugin'
? db()
.prepare(
`SELECT i.server, v.version, v.id AS versionId, i.updated_at AS recordedAt FROM server_scripts i JOIN script_versions v ON v.id = i.script_version_id WHERE v.script_id = ? AND i.rel_path = ? ORDER BY i.server`
`SELECT i.server, v.version, v.release_version, v.id AS versionId, i.updated_at AS recordedAt FROM server_scripts i JOIN script_versions v ON v.id = i.script_version_id WHERE v.script_id = ? AND i.rel_path = ? ORDER BY i.server`
)
.all(id, script.target_rel_path)
: [],
+13 -12
View File
@@ -1,8 +1,11 @@
import { artifactFormBody } from '$lib/server/request-body.js';
import { json } from '@sveltejs/kit';
import { uploadNewVersion, getScript, ScriptError } from '$lib/server/fleet/scripts.js';
import { createHash } from 'node:crypto';
import { db } from '$lib/server/db/index.js';
import {
uploadNewVersion,
getScript,
getLatestVersion,
ScriptError
} from '$lib/server/fleet/scripts.js';
import { findRunningBetaForScript } from '$lib/server/fleet/beta.js';
function parseId(idRaw: string): number | null {
@@ -11,7 +14,7 @@ function parseId(idRaw: string): number | null {
}
// Multipart POST — creates a new version from an uploaded file. Fields:
// content (file), notes and releaseVersion (optional). Response includes a "betaRequired" hint
// content (file), notes (optional) and releaseVersion (required for scripts). Response includes a "betaRequired" hint
// when the script has a scope so the UI can nudge the operator to open a beta.
export async function POST({ params, request }: { params: { id: string }; request: Request }) {
const scriptId = parseId(params.id);
@@ -45,21 +48,16 @@ export async function POST({ params, request }: { params: { id: string }; reques
{ status: 409 }
);
}
const sha = createHash('sha256').update(content).digest('hex');
const existing = db()
.prepare(
'SELECT id, version, sha256, size FROM script_versions WHERE script_id = ? AND sha256 = ? ORDER BY version DESC LIMIT 1'
)
.get(scriptId, sha);
if (existing) return json({ ok: true, version: existing, reused: true });
}
try {
const releaseVersion = form.has('releaseVersion')
? String(form.get('releaseVersion'))
: undefined;
const previous = getLatestVersion(scriptId);
const version = uploadNewVersion(scriptId, content, notes, releaseVersion);
return json({
ok: true,
reused: script.kind === 'script' && version.version <= (previous?.version ?? 0),
version: {
id: version.id,
version: version.version,
@@ -73,7 +71,10 @@ export async function POST({ params, request }: { params: { id: string }; reques
});
} catch (err) {
if (err instanceof ScriptError) {
return json({ ok: false, error: err.message, kind: err.kind }, { status: 409 });
return json(
{ ok: false, error: err.message, kind: err.kind },
{ status: err.kind === 'invalid-release-version' ? 400 : 409 }
);
}
throw err;
}
+1 -1
View File
@@ -15,7 +15,7 @@ export async function POST({ request }: { request: Request }) {
.digest('hex');
const duplicates = db()
.prepare(
`SELECT v.script_id AS scriptId, v.id AS versionId, v.version
`SELECT v.script_id AS scriptId, v.id AS versionId, v.version, v.release_version
FROM script_versions v JOIN scripts s ON s.id = v.script_id
WHERE s.kind = 'script' AND v.sha256 = ? ORDER BY v.version DESC`
)
+5 -1
View File
@@ -261,7 +261,11 @@
>
<td class="files"
>{#if beta.scripts.length === 1}{beta.scripts[0].name}
<span>{artifactVersionLabel(beta.scripts[0].version)}</span
<span
>{artifactVersionLabel(
beta.scripts[0].version,
beta.scripts[0].kind
)}</span
>{:else}{beta.scripts.length} files{/if}</td
>
<td
+7 -3
View File
@@ -620,7 +620,7 @@
$page.url
)}>{file.name}</a
>
· {artifactVersionLabel(file.version)}</summary
· {artifactVersionLabel(file.version, file.kind)}</summary
>
<p><code>{file.target_rel_path}</code></p>
{#if file.version}<p class="muted">
@@ -661,7 +661,10 @@
<a
href={`/api/betas/${id}/revisions/${item.revision}/files/${file.script_id}`}
download={file.path.split('/').pop()}
>{file.name} · {artifactVersionLabel(file)}</a
>{file.name} · {artifactVersionLabel(
file,
file.kind
)}</a
><code>{file.path}</code><span
>{file.sha256.slice(0, 12)}</span
>
@@ -744,7 +747,8 @@
<div class="file-list">
{#each preview.files as file}<details open>
<summary
>{file.name} · {artifactVersionLabel(file)} · {file.servers.length} servers</summary
>{file.name} · {artifactVersionLabel(file, file.kind)} · {file.servers
.length} servers</summary
>
<p><code>{file.path}</code> · {file.scope} · SHA {file.sha256.slice(0, 12)}</p>
<ul>
+10 -5
View File
@@ -712,10 +712,14 @@
? 'Loading versions…'
: 'Choose a version'}</option
>{#each versionsFor(pick) as version}<option value={version.id}
>{artifactVersionLabel(version)} · {version.sha256.slice(
0,
8
)} · {version.state.replaceAll('-', ' ')}</option
>{artifactVersionLabel(
version,
scripts.find((file) => file.id === pick.scriptId)
?.kind
)} · {version.sha256.slice(0, 8)} · {version.state.replaceAll(
'-',
' '
)}</option
>{/each}</Select
></Field
>
@@ -926,7 +930,8 @@
?.name}</strong
>
· {artifactVersionLabel(
versionsFor(pick).find((v) => v.id === pick.versionId)
versionsFor(pick).find((v) => v.id === pick.versionId),
scripts.find((file) => file.id === pick.scriptId)?.kind
)} · {versionsFor(pick)
.find((v) => v.id === pick.versionId)
?.sha256.slice(
+6 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { artifactVersionLabel } from '$lib/artifact-version.js';
import Select from '$ui/components/primitives/Select.svelte';
import Input from '$ui/components/primitives/Input.svelte';
import Field from '$ui/components/primitives/Field.svelte';
@@ -336,6 +337,7 @@
<td>{scopeLabel(s)}</td>
<td
><LibraryVersions
kind="script"
current={s.currentVersion}
latest={s.latestVersion}
betaId={s.runningBetaId}
@@ -389,7 +391,10 @@
>
{#if reviewSelection}<ul class="selection-review">
{#each selectedScripts as script}<li>
{script.name} · v{script.latestVersion?.version}<Button
{script.name} · {artifactVersionLabel(
script.latestVersion,
'script'
)}<Button
size="sm"
variant="ghost"
onclick={() => toggle(script.id)}
+10 -2
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { artifactVersionLabel } from '$lib/artifact-version.js';
import RelativeTime from '$ui/components/RelativeTime.svelte';
import { tick } from 'svelte';
import ActivityLog from '$ui/components/ActivityLog.svelte';
@@ -564,8 +565,15 @@
)}>{f.relPath}</a
>{:else}{f.relPath}{/if}</td
><td>{f.kind ?? 'file'}</td><td
>{f.releaseVersion ??
(f.version ? `v${f.version}` : '—')}</td
>{artifactVersionLabel(
f.version
? {
version: f.version,
release_version: f.releaseVersion
}
: null,
f.kind ?? undefined
)}</td
><td
><details>
<summary
+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'