This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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] : '';
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
: [],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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`
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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() {}')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }],
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -55,6 +55,7 @@ async function setup(capable = true) {
|
||||
targetRelPath?: string
|
||||
) {
|
||||
const artifact = createScript({
|
||||
releaseVersion: '1.0.0',
|
||||
name,
|
||||
kind,
|
||||
targetRelPath,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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(){}')
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 = ?')
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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')
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)');
|
||||
});
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user