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.
|
copied into a beta; later edits do not change an active plan.
|
||||||
|
|
||||||
`POST /api/betas/:id/revisions` accepts multipart `expectedRevision`, optional
|
`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`
|
rejected. `GET /api/betas/:id` includes the current `revision` and `revisions`
|
||||||
history; `GET /api/betas/:id/revisions/:revision/files/:scriptId` downloads an
|
history; `GET /api/betas/:id/revisions/:revision/files/:scriptId` downloads an
|
||||||
archived file (or the original pinned file before its first update).
|
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.
|
verified stopped container. A rebuild removes the instance's writable files.
|
||||||
See [Provisioning](provisioning.md) and [Collections](collections.md) for details.
|
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
|
## Connect game telemetry
|
||||||
|
|
||||||
Configure your game reporter or plugin to send `EC_TELEMETRY_TOKEN` in an
|
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. */
|
/** Keep the upload number visible: two builds may share a publisher's release tag. */
|
||||||
export function artifactVersionLabel(
|
export function artifactVersionLabel(
|
||||||
version: { version: number; release_version?: string | null } | null | undefined
|
version: { version: number; release_version?: string | null } | null | undefined,
|
||||||
|
kind?: string
|
||||||
): string {
|
): string {
|
||||||
if (!version) return '—';
|
if (!version) return '—';
|
||||||
return version.release_version
|
return version.release_version
|
||||||
? `${version.release_version} (upload ${version.version})`
|
? `${version.release_version} (upload ${version.version})`
|
||||||
: `v${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 {
|
export interface LibraryVersion {
|
||||||
id: number;
|
id: number;
|
||||||
version: number;
|
version: number;
|
||||||
|
release_version?: string | null;
|
||||||
sha256: string;
|
sha256: string;
|
||||||
size: number;
|
size: number;
|
||||||
state: 'untested' | 'tested' | 'test-failed';
|
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 { putFileBlob, blobContent } from '../db/file-blobs.js';
|
||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
import { db } from '../db/index.js';
|
import { db } from '../db/index.js';
|
||||||
@@ -73,6 +74,7 @@ export class ScriptError extends Error {
|
|||||||
| 'baseline-exists'
|
| 'baseline-exists'
|
||||||
| 'empty-overlay'
|
| 'empty-overlay'
|
||||||
| 'invalid-release-version'
|
| 'invalid-release-version'
|
||||||
|
| 'release-version-conflict'
|
||||||
| 'too-large'
|
| 'too-large'
|
||||||
) {
|
) {
|
||||||
super(message);
|
super(message);
|
||||||
@@ -95,6 +97,14 @@ function normalizeReleaseVersion(value: string | null | undefined): string | nul
|
|||||||
return value.trim() || null;
|
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 {
|
export function getScript(id: number): ScriptRow | undefined {
|
||||||
return db().prepare('SELECT * FROM scripts WHERE id = ?').get(id) as 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');
|
throw new ScriptError(`script "${input.name}" already exists`, 'name-taken');
|
||||||
|
|
||||||
const sha = sha256(input.content);
|
const sha = sha256(input.content);
|
||||||
const releaseVersion = normalizeReleaseVersion(input.releaseVersion);
|
const releaseVersion = releaseForKind(kind, input.releaseVersion);
|
||||||
const size = input.content.byteLength;
|
const size = input.content.byteLength;
|
||||||
|
|
||||||
const insertScript = db().prepare(
|
const insertScript = db().prepare(
|
||||||
@@ -335,27 +345,39 @@ export function uploadNewVersion(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const latest = getLatestVersion(scriptId);
|
const release = releaseForKind(script.kind, releaseVersion);
|
||||||
const nextVersion = (latest?.version ?? 0) + 1;
|
|
||||||
const sha = sha256(content);
|
const sha = sha256(content);
|
||||||
const release = normalizeReleaseVersion(releaseVersion);
|
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 res = db()
|
const res = db()
|
||||||
.prepare(
|
.prepare(
|
||||||
`INSERT INTO script_versions (script_id, version, sha256, size, content_hash, notes, kind, release_version)
|
`INSERT INTO script_versions (script_id, version, sha256, size, content_hash, notes, kind, release_version)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
)
|
)
|
||||||
.run(
|
.run(
|
||||||
scriptId,
|
scriptId,
|
||||||
nextVersion,
|
nextVersion,
|
||||||
sha,
|
sha,
|
||||||
content.byteLength,
|
content.byteLength,
|
||||||
putFileBlob(content),
|
putFileBlob(content),
|
||||||
notes ?? null,
|
notes ?? null,
|
||||||
script.kind,
|
script.kind,
|
||||||
release
|
release
|
||||||
);
|
);
|
||||||
return getVersion(Number(res.lastInsertRowid))!;
|
return getVersion(Number(res.lastInsertRowid))!;
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
function validateScope(scopeType: ScopeType | null, scopeValue: string | null): void {
|
function validateScope(scopeType: ScopeType | null, scopeValue: string | null): void {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { manifestVersion, scriptVersionError } from '$lib/script-version.js';
|
||||||
|
import ScriptVersionManifest from './ScriptVersionManifest.svelte';
|
||||||
import { serverTitle } from '$ui/server-labels.js';
|
import { serverTitle } from '$ui/server-labels.js';
|
||||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||||
import { page } from '$app/stores';
|
import { page } from '$app/stores';
|
||||||
@@ -69,7 +71,13 @@
|
|||||||
let versions = $state<Version[]>([]);
|
let versions = $state<Version[]>([]);
|
||||||
let matchedServers = $state<string[]>([]);
|
let matchedServers = $state<string[]>([]);
|
||||||
let installedVersions = $state<
|
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 {
|
interface ScopeImpact {
|
||||||
before: string[];
|
before: string[];
|
||||||
@@ -103,6 +111,7 @@
|
|||||||
let versionFile = $state<File | null>(null);
|
let versionFile = $state<File | null>(null);
|
||||||
let versionNotes = $state('');
|
let versionNotes = $state('');
|
||||||
let releaseVersion = $state('');
|
let releaseVersion = $state('');
|
||||||
|
let versionManifest = $state<Record<string, string>>({});
|
||||||
let uploadingVersion = $state(false);
|
let uploadingVersion = $state(false);
|
||||||
|
|
||||||
let scopeDialog = $state<HTMLDialogElement | null>(null);
|
let scopeDialog = $state<HTMLDialogElement | null>(null);
|
||||||
@@ -244,6 +253,7 @@
|
|||||||
function openUploadModal(): void {
|
function openUploadModal(): void {
|
||||||
if (!editable || runningBetaId !== null) return;
|
if (!editable || runningBetaId !== null) return;
|
||||||
releaseVersion = '';
|
releaseVersion = '';
|
||||||
|
versionManifest = {};
|
||||||
versionFile = null;
|
versionFile = null;
|
||||||
versionNotes = '';
|
versionNotes = '';
|
||||||
flash = null;
|
flash = null;
|
||||||
@@ -271,16 +281,23 @@
|
|||||||
function pickVersionFile(e: Event): void {
|
function pickVersionFile(e: Event): void {
|
||||||
const t = e.target as HTMLInputElement;
|
const t = e.target as HTMLInputElement;
|
||||||
versionFile = t.files?.[0] ?? null;
|
versionFile = t.files?.[0] ?? null;
|
||||||
|
if (!releaseVersion.trim() && versionFile)
|
||||||
|
releaseVersion = manifestVersion(versionManifest, versionFile.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadVersion(e: Event): Promise<void> {
|
async function uploadVersion(e: Event): Promise<void> {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!editable || !versionFile || uploadingVersion) return;
|
if (!editable || !versionFile || uploadingVersion) return;
|
||||||
|
if (family === 'script' && scriptVersionError(releaseVersion)) {
|
||||||
|
flash = scriptVersionError(releaseVersion);
|
||||||
|
return;
|
||||||
|
}
|
||||||
uploadingVersion = true;
|
uploadingVersion = true;
|
||||||
flash = null;
|
flash = null;
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append('content', versionFile);
|
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());
|
if (versionNotes.trim()) form.append('notes', versionNotes.trim());
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/scripts/${id}/versions`, { method: 'POST', body: form });
|
const res = await fetch(`/api/scripts/${id}/versions`, { method: 'POST', body: form });
|
||||||
@@ -419,7 +436,7 @@
|
|||||||
{#if latestVersion}<Card padding="lg"
|
{#if latestVersion}<Card padding="lg"
|
||||||
><div class="release-summary">
|
><div class="release-summary">
|
||||||
<div>
|
<div>
|
||||||
<h2>Latest {artifactVersionLabel(latestVersion)}</h2>
|
<h2>Latest {artifactVersionLabel(latestVersion, family)}</h2>
|
||||||
<StatusBadge
|
<StatusBadge
|
||||||
state={latestVersion.id === a.current_version_id
|
state={latestVersion.id === a.current_version_id
|
||||||
? 'promoted'
|
? 'promoted'
|
||||||
@@ -517,7 +534,7 @@
|
|||||||
<span class="k">Promoted version</span>
|
<span class="k">Promoted version</span>
|
||||||
<span class="v mono">
|
<span class="v mono">
|
||||||
{#if currentVersion}
|
{#if currentVersion}
|
||||||
{artifactVersionLabel(currentVersion)} · {currentVersion.sha256.slice(
|
{artifactVersionLabel(currentVersion, family)} · {currentVersion.sha256.slice(
|
||||||
0,
|
0,
|
||||||
8
|
8
|
||||||
)}
|
)}
|
||||||
@@ -541,7 +558,9 @@
|
|||||||
<a href={'/servers/' + encodeURIComponent(installed.server)}
|
<a href={'/servers/' + encodeURIComponent(installed.server)}
|
||||||
>{serverTitle(installed.server)}</a
|
>{serverTitle(installed.server)}</a
|
||||||
>
|
>
|
||||||
· v{installed.version} · <RelativeTime value={installed.recordedAt} />
|
· {artifactVersionLabel(installed, family)} · <RelativeTime
|
||||||
|
value={installed.recordedAt}
|
||||||
|
/>
|
||||||
</li>{/each}
|
</li>{/each}
|
||||||
</ul>
|
</ul>
|
||||||
</details>{:else if family !== 'config'}<p class="dim">
|
</details>{:else if family !== 'config'}<p class="dim">
|
||||||
@@ -575,7 +594,7 @@
|
|||||||
<div class="version-list">
|
<div class="version-list">
|
||||||
{#each versions.slice(0, limit) as v (v.id)}
|
{#each versions.slice(0, limit) as v (v.id)}
|
||||||
<div class="version-row" class:current={a.current_version_id === 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"
|
<span class="v-notes"
|
||||||
>{v.notes ?? ''}
|
>{v.notes ?? ''}
|
||||||
{#if v.source}<a
|
{#if v.source}<a
|
||||||
@@ -750,14 +769,28 @@
|
|||||||
disabled={uploadingVersion}
|
disabled={uploadingVersion}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</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
|
<Field
|
||||||
label="Release version"
|
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
|
<Input
|
||||||
bind:value={releaseVersion}
|
bind:value={releaseVersion}
|
||||||
placeholder="v3.1.0"
|
placeholder={family === 'script' ? '1.2.0' : 'v3.1.0'}
|
||||||
|
required={family === 'script'}
|
||||||
|
maxlength={80}
|
||||||
disabled={uploadingVersion}
|
disabled={uploadingVersion}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
<script lang="ts">
|
<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 Dialog from './primitives/Dialog.svelte';
|
||||||
import Button from './primitives/Button.svelte';
|
import Button from './primitives/Button.svelte';
|
||||||
import DiffViewer from './DiffViewer.svelte';
|
import DiffViewer from './DiffViewer.svelte';
|
||||||
@@ -31,19 +35,38 @@
|
|||||||
} = $props();
|
} = $props();
|
||||||
interface Replacement {
|
interface Replacement {
|
||||||
file: File;
|
file: File;
|
||||||
|
releaseVersion: string;
|
||||||
sha: string;
|
sha: string;
|
||||||
before: string | null;
|
before: string | null;
|
||||||
after: string | null;
|
after: string | null;
|
||||||
}
|
}
|
||||||
|
let manifest = $state<Record<string, string>>({});
|
||||||
let replacements = $state<Record<number, Replacement>>({});
|
let replacements = $state<Record<number, Replacement>>({});
|
||||||
let notes = $state(''),
|
let notes = $state(''),
|
||||||
error = $state(''),
|
error = $state(''),
|
||||||
reviewing = $state(false),
|
reviewing = $state(false),
|
||||||
busy = $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);
|
const count = $derived(Object.keys(replacements).length);
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
replacements = {};
|
replacements = {};
|
||||||
|
manifest = {};
|
||||||
notes = '';
|
notes = '';
|
||||||
error = '';
|
error = '';
|
||||||
reviewing = false;
|
reviewing = false;
|
||||||
@@ -90,7 +113,13 @@
|
|||||||
after = new TextDecoder().decode(bytes);
|
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) {
|
} catch (err) {
|
||||||
error = (err as Error).message;
|
error = (err as Error).message;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -98,14 +127,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function deploy() {
|
async function deploy() {
|
||||||
|
if (!versionsValid) return;
|
||||||
busy = true;
|
busy = true;
|
||||||
error = '';
|
error = '';
|
||||||
try {
|
try {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.set('expectedRevision', String(revision));
|
form.set('expectedRevision', String(revision));
|
||||||
form.set('notes', notes);
|
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(`file:${scriptId}`, replacement.file);
|
||||||
|
form.set(`release:${scriptId}`, replacement.releaseVersion.trim());
|
||||||
|
}
|
||||||
const response = await fetch(`/api/betas/${betaId}/revisions`, {
|
const response = await fetch(`/api/betas/${betaId}/revisions`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: form
|
body: form
|
||||||
@@ -132,12 +164,15 @@
|
|||||||
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
|
{#if error}<p class="ui-error" role="alert">{error}</p>{/if}
|
||||||
{#if !reviewing}
|
{#if !reviewing}
|
||||||
<p>Choose replacements for the files you changed.</p>
|
<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">
|
<div class="files">
|
||||||
{#each files as entry}
|
{#each files as entry}
|
||||||
<label
|
<label
|
||||||
><strong>{entry.name} · {artifactVersionLabel(entry.version)}</strong><code
|
><strong
|
||||||
>{entry.target_rel_path}</code
|
>{entry.name} · {artifactVersionLabel(entry.version, entry.kind)}</strong
|
||||||
>
|
><code>{entry.target_rel_path}</code>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
disabled={busy}
|
disabled={busy}
|
||||||
@@ -145,6 +180,26 @@
|
|||||||
/>
|
/>
|
||||||
{#if replacements[entry.id]}<span>Replacement ready for review</span>{/if}
|
{#if replacements[entry.id]}<span>Replacement ready for review</span>{/if}
|
||||||
</label>
|
</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}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
<label class="notes"
|
<label class="notes"
|
||||||
@@ -177,6 +232,9 @@
|
|||||||
{@const replacement = replacements[entry.id]}
|
{@const replacement = replacements[entry.id]}
|
||||||
<section>
|
<section>
|
||||||
<h3>{entry.name}</h3>
|
<h3>{entry.name}</h3>
|
||||||
|
{#if replacement.releaseVersion}<p>
|
||||||
|
Release {entry.version?.release_version ?? 'Unversioned'} → {replacement.releaseVersion.trim()}
|
||||||
|
</p>{/if}
|
||||||
<p>
|
<p>
|
||||||
<code>{entry.target_rel_path}</code> ← {replacement.file.name} ({replacement.file.size.toLocaleString()}
|
<code>{entry.target_rel_path}</code> ← {replacement.file.name} ({replacement.file.size.toLocaleString()}
|
||||||
bytes)
|
bytes)
|
||||||
@@ -204,7 +262,7 @@
|
|||||||
{:else}<Button
|
{:else}<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
onclick={() => (reviewing = true)}
|
onclick={() => (reviewing = true)}
|
||||||
disabled={busy || !count}>Review update</Button
|
disabled={busy || !count || !versionsValid}>Review update</Button
|
||||||
>{/if}
|
>{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -10,15 +10,21 @@
|
|||||||
let {
|
let {
|
||||||
current,
|
current,
|
||||||
latest,
|
latest,
|
||||||
|
kind,
|
||||||
betaId = null
|
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 newer = $derived(latest && latest.id !== current?.id ? latest : null);
|
||||||
const ambiguous = $derived(
|
const ambiguous = $derived(
|
||||||
!!newer?.release_version && newer.release_version === current?.release_version
|
!!newer?.release_version && newer.release_version === current?.release_version
|
||||||
);
|
);
|
||||||
function label(version: Version) {
|
function label(version: Version) {
|
||||||
return ambiguous
|
return kind === 'script' || ambiguous
|
||||||
? artifactVersionLabel(version)
|
? artifactVersionLabel(version, kind)
|
||||||
: version.release_version || `v${version.version}`;
|
: version.release_version || `v${version.version}`;
|
||||||
}
|
}
|
||||||
const state = $derived(
|
const state = $derived(
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
<script lang="ts">
|
<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 Input from '$ui/components/primitives/Input.svelte';
|
||||||
import Select from '$ui/components/primitives/Select.svelte';
|
import Select from '$ui/components/primitives/Select.svelte';
|
||||||
import Field from '$ui/components/primitives/Field.svelte';
|
import Field from '$ui/components/primitives/Field.svelte';
|
||||||
@@ -20,23 +23,34 @@
|
|||||||
scriptId: number;
|
scriptId: number;
|
||||||
versionId: number;
|
versionId: number;
|
||||||
version: number;
|
version: number;
|
||||||
|
release_version: string | null;
|
||||||
}
|
}
|
||||||
interface Upload {
|
interface Upload {
|
||||||
key: number;
|
key: number;
|
||||||
file: File;
|
file: File;
|
||||||
target: number | 'new' | '';
|
target: number | 'new' | '';
|
||||||
name: string;
|
name: string;
|
||||||
|
releaseVersion: string;
|
||||||
duplicates: Duplicate[];
|
duplicates: Duplicate[];
|
||||||
inspecting: boolean;
|
inspecting: boolean;
|
||||||
inspected: boolean;
|
inspected: boolean;
|
||||||
error: string;
|
error: string;
|
||||||
result: (BetaSelection & { version: number; reused: boolean }) | null;
|
result:
|
||||||
|
(BetaSelection & { version: number; release_version: string; reused: boolean }) | null;
|
||||||
}
|
}
|
||||||
let dialog: HTMLDialogElement;
|
let dialog: HTMLDialogElement;
|
||||||
let input: HTMLInputElement;
|
let input: HTMLInputElement;
|
||||||
let queue = $state<Upload[]>([]);
|
let queue = $state<Upload[]>([]);
|
||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
let notes = $state('');
|
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 scopeType = $state<'none' | 'game' | 'map'>('none');
|
||||||
let scopeGame = $state('');
|
let scopeGame = $state('');
|
||||||
let scopeMap = $state('');
|
let scopeMap = $state('');
|
||||||
@@ -80,6 +94,7 @@
|
|||||||
// only when there is no pending file to lose.
|
// only when there is no pending file to lose.
|
||||||
if (!pending.length) {
|
if (!pending.length) {
|
||||||
queue = [];
|
queue = [];
|
||||||
|
manifest = {};
|
||||||
notes = '';
|
notes = '';
|
||||||
message = '';
|
message = '';
|
||||||
preferredTarget = scriptId ?? null;
|
preferredTarget = scriptId ?? null;
|
||||||
@@ -121,6 +136,7 @@
|
|||||||
preferredTarget ??
|
preferredTarget ??
|
||||||
(matches.length === 1 ? matches[0].id : matches.length > 1 ? '' : 'new'),
|
(matches.length === 1 ? matches[0].id : matches.length > 1 ? '' : 'new'),
|
||||||
name: file.name.replace(/\.[^.]+$/, ''),
|
name: file.name.replace(/\.[^.]+$/, ''),
|
||||||
|
releaseVersion: manifestVersion(manifest, file.name),
|
||||||
duplicates: [],
|
duplicates: [],
|
||||||
inspecting: true,
|
inspecting: true,
|
||||||
inspected: false,
|
inspected: false,
|
||||||
@@ -153,9 +169,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
function duplicate(item: Upload) {
|
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 {
|
function validation(item: Upload): string {
|
||||||
|
const versionError = scriptVersionError(item.releaseVersion);
|
||||||
|
if (versionError) return versionError;
|
||||||
if (item.target === '')
|
if (item.target === '')
|
||||||
return 'Several scripts use this filename. Choose which script to update.';
|
return 'Several scripts use this filename. Choose which script to update.';
|
||||||
if (item.target === 'new') {
|
if (item.target === 'new') {
|
||||||
@@ -194,6 +214,7 @@
|
|||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append('content', item.file);
|
form.append('content', item.file);
|
||||||
form.append('notes', notes.trim());
|
form.append('notes', notes.trim());
|
||||||
|
form.append('releaseVersion', item.releaseVersion.trim());
|
||||||
const creating = item.target === 'new';
|
const creating = item.target === 'new';
|
||||||
if (creating) {
|
if (creating) {
|
||||||
form.append('name', item.name.trim());
|
form.append('name', item.name.trim());
|
||||||
@@ -211,6 +232,7 @@
|
|||||||
scriptId: creating ? body.script.id : (item.target as number),
|
scriptId: creating ? body.script.id : (item.target as number),
|
||||||
versionId: creating ? body.versionId : body.version.id,
|
versionId: creating ? body.versionId : body.version.id,
|
||||||
version: creating ? 1 : body.version.version,
|
version: creating ? 1 : body.version.version,
|
||||||
|
release_version: item.releaseVersion.trim(),
|
||||||
reused: !!body.reused
|
reused: !!body.reused
|
||||||
};
|
};
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -286,6 +308,7 @@
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<ScriptVersionManifest disabled={busy || inspecting} onload={applyManifest} />
|
||||||
<div class="queue" aria-live="polite">
|
<div class="queue" aria-live="polite">
|
||||||
{#each queue as item (item.key)}
|
{#each queue as item (item.key)}
|
||||||
<section class="upload-item" aria-label={item.file.name}>
|
<section class="upload-item" aria-label={item.file.name}>
|
||||||
@@ -304,11 +327,26 @@
|
|||||||
</div>
|
</div>
|
||||||
{#if item.result}
|
{#if item.result}
|
||||||
<p class="success">
|
<p class="success">
|
||||||
{item.result.reused ? 'Already uploaded' : 'Uploaded'} as v{item.result
|
{item.result.reused ? 'Already uploaded' : 'Uploaded'} as {artifactVersionLabel(
|
||||||
.version} ·
|
item.result,
|
||||||
|
'script'
|
||||||
|
)} ·
|
||||||
<a href="/scripts/{item.result.scriptId}">View script →</a>
|
<a href="/scripts/{item.result.scriptId}">View script →</a>
|
||||||
</p>
|
</p>
|
||||||
{:else}
|
{: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">
|
<Field label="Destination">
|
||||||
<Select
|
<Select
|
||||||
bind:value={item.target}
|
bind:value={item.target}
|
||||||
@@ -336,8 +374,10 @@
|
|||||||
</p>{/if}
|
</p>{/if}
|
||||||
{#if item.inspecting}<p class="note">Checking file contents…</p>
|
{#if item.inspecting}<p class="note">Checking file contents…</p>
|
||||||
{:else if duplicate(item)}<p class="success">
|
{:else if duplicate(item)}<p class="success">
|
||||||
Already uploaded as v{duplicate(item)!.version}. We’ll reuse that
|
Already uploaded as {artifactVersionLabel(
|
||||||
version.
|
duplicate(item),
|
||||||
|
'script'
|
||||||
|
)}. We’ll reuse that version.
|
||||||
</p>
|
</p>
|
||||||
{:else if item.target !== 'new' && item.target !== ''}<p class="note">
|
{:else if item.target !== 'new' && item.target !== ''}<p class="note">
|
||||||
New version for {scripts.find((s) => s.id === item.target)?.name}
|
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;
|
if (isHttpError(error)) throw error;
|
||||||
return json(
|
return json(
|
||||||
{ ok: false, error: (error as Error).message },
|
{ 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 });
|
return json({ ok: true, script, versionId: version.id });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ScriptError) {
|
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;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export async function GET({ params }: { params: { id: string } }) {
|
|||||||
script.kind === 'script' || script.kind === 'plugin'
|
script.kind === 'script' || script.kind === 'plugin'
|
||||||
? db()
|
? db()
|
||||||
.prepare(
|
.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)
|
.all(id, script.target_rel_path)
|
||||||
: [],
|
: [],
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { artifactFormBody } from '$lib/server/request-body.js';
|
import { artifactFormBody } from '$lib/server/request-body.js';
|
||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { uploadNewVersion, getScript, ScriptError } from '$lib/server/fleet/scripts.js';
|
import {
|
||||||
import { createHash } from 'node:crypto';
|
uploadNewVersion,
|
||||||
import { db } from '$lib/server/db/index.js';
|
getScript,
|
||||||
|
getLatestVersion,
|
||||||
|
ScriptError
|
||||||
|
} from '$lib/server/fleet/scripts.js';
|
||||||
import { findRunningBetaForScript } from '$lib/server/fleet/beta.js';
|
import { findRunningBetaForScript } from '$lib/server/fleet/beta.js';
|
||||||
|
|
||||||
function parseId(idRaw: string): number | null {
|
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:
|
// 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.
|
// 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 }) {
|
export async function POST({ params, request }: { params: { id: string }; request: Request }) {
|
||||||
const scriptId = parseId(params.id);
|
const scriptId = parseId(params.id);
|
||||||
@@ -45,21 +48,16 @@ export async function POST({ params, request }: { params: { id: string }; reques
|
|||||||
{ status: 409 }
|
{ 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 {
|
try {
|
||||||
const releaseVersion = form.has('releaseVersion')
|
const releaseVersion = form.has('releaseVersion')
|
||||||
? String(form.get('releaseVersion'))
|
? String(form.get('releaseVersion'))
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const previous = getLatestVersion(scriptId);
|
||||||
const version = uploadNewVersion(scriptId, content, notes, releaseVersion);
|
const version = uploadNewVersion(scriptId, content, notes, releaseVersion);
|
||||||
return json({
|
return json({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
reused: script.kind === 'script' && version.version <= (previous?.version ?? 0),
|
||||||
version: {
|
version: {
|
||||||
id: version.id,
|
id: version.id,
|
||||||
version: version.version,
|
version: version.version,
|
||||||
@@ -73,7 +71,10 @@ export async function POST({ params, request }: { params: { id: string }; reques
|
|||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ScriptError) {
|
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;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export async function POST({ request }: { request: Request }) {
|
|||||||
.digest('hex');
|
.digest('hex');
|
||||||
const duplicates = db()
|
const duplicates = db()
|
||||||
.prepare(
|
.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
|
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`
|
WHERE s.kind = 'script' AND v.sha256 = ? ORDER BY v.version DESC`
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -261,7 +261,11 @@
|
|||||||
>
|
>
|
||||||
<td class="files"
|
<td class="files"
|
||||||
>{#if beta.scripts.length === 1}{beta.scripts[0].name}
|
>{#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
|
>{:else}{beta.scripts.length} files{/if}</td
|
||||||
>
|
>
|
||||||
<td
|
<td
|
||||||
|
|||||||
@@ -620,7 +620,7 @@
|
|||||||
$page.url
|
$page.url
|
||||||
)}>{file.name}</a
|
)}>{file.name}</a
|
||||||
>
|
>
|
||||||
· {artifactVersionLabel(file.version)}</summary
|
· {artifactVersionLabel(file.version, file.kind)}</summary
|
||||||
>
|
>
|
||||||
<p><code>{file.target_rel_path}</code></p>
|
<p><code>{file.target_rel_path}</code></p>
|
||||||
{#if file.version}<p class="muted">
|
{#if file.version}<p class="muted">
|
||||||
@@ -661,7 +661,10 @@
|
|||||||
<a
|
<a
|
||||||
href={`/api/betas/${id}/revisions/${item.revision}/files/${file.script_id}`}
|
href={`/api/betas/${id}/revisions/${item.revision}/files/${file.script_id}`}
|
||||||
download={file.path.split('/').pop()}
|
download={file.path.split('/').pop()}
|
||||||
>{file.name} · {artifactVersionLabel(file)}</a
|
>{file.name} · {artifactVersionLabel(
|
||||||
|
file,
|
||||||
|
file.kind
|
||||||
|
)}</a
|
||||||
><code>{file.path}</code><span
|
><code>{file.path}</code><span
|
||||||
>{file.sha256.slice(0, 12)}</span
|
>{file.sha256.slice(0, 12)}</span
|
||||||
>
|
>
|
||||||
@@ -744,7 +747,8 @@
|
|||||||
<div class="file-list">
|
<div class="file-list">
|
||||||
{#each preview.files as file}<details open>
|
{#each preview.files as file}<details open>
|
||||||
<summary
|
<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>
|
<p><code>{file.path}</code> · {file.scope} · SHA {file.sha256.slice(0, 12)}</p>
|
||||||
<ul>
|
<ul>
|
||||||
|
|||||||
@@ -712,10 +712,14 @@
|
|||||||
? 'Loading versions…'
|
? 'Loading versions…'
|
||||||
: 'Choose a version'}</option
|
: 'Choose a version'}</option
|
||||||
>{#each versionsFor(pick) as version}<option value={version.id}
|
>{#each versionsFor(pick) as version}<option value={version.id}
|
||||||
>{artifactVersionLabel(version)} · {version.sha256.slice(
|
>{artifactVersionLabel(
|
||||||
0,
|
version,
|
||||||
8
|
scripts.find((file) => file.id === pick.scriptId)
|
||||||
)} · {version.state.replaceAll('-', ' ')}</option
|
?.kind
|
||||||
|
)} · {version.sha256.slice(0, 8)} · {version.state.replaceAll(
|
||||||
|
'-',
|
||||||
|
' '
|
||||||
|
)}</option
|
||||||
>{/each}</Select
|
>{/each}</Select
|
||||||
></Field
|
></Field
|
||||||
>
|
>
|
||||||
@@ -926,7 +930,8 @@
|
|||||||
?.name}</strong
|
?.name}</strong
|
||||||
>
|
>
|
||||||
· {artifactVersionLabel(
|
· {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)
|
)} · {versionsFor(pick)
|
||||||
.find((v) => v.id === pick.versionId)
|
.find((v) => v.id === pick.versionId)
|
||||||
?.sha256.slice(
|
?.sha256.slice(
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { artifactVersionLabel } from '$lib/artifact-version.js';
|
||||||
import Select from '$ui/components/primitives/Select.svelte';
|
import Select from '$ui/components/primitives/Select.svelte';
|
||||||
import Input from '$ui/components/primitives/Input.svelte';
|
import Input from '$ui/components/primitives/Input.svelte';
|
||||||
import Field from '$ui/components/primitives/Field.svelte';
|
import Field from '$ui/components/primitives/Field.svelte';
|
||||||
@@ -336,6 +337,7 @@
|
|||||||
<td>{scopeLabel(s)}</td>
|
<td>{scopeLabel(s)}</td>
|
||||||
<td
|
<td
|
||||||
><LibraryVersions
|
><LibraryVersions
|
||||||
|
kind="script"
|
||||||
current={s.currentVersion}
|
current={s.currentVersion}
|
||||||
latest={s.latestVersion}
|
latest={s.latestVersion}
|
||||||
betaId={s.runningBetaId}
|
betaId={s.runningBetaId}
|
||||||
@@ -389,7 +391,10 @@
|
|||||||
>
|
>
|
||||||
{#if reviewSelection}<ul class="selection-review">
|
{#if reviewSelection}<ul class="selection-review">
|
||||||
{#each selectedScripts as script}<li>
|
{#each selectedScripts as script}<li>
|
||||||
{script.name} · v{script.latestVersion?.version}<Button
|
{script.name} · {artifactVersionLabel(
|
||||||
|
script.latestVersion,
|
||||||
|
'script'
|
||||||
|
)}<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onclick={() => toggle(script.id)}
|
onclick={() => toggle(script.id)}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { artifactVersionLabel } from '$lib/artifact-version.js';
|
||||||
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
import RelativeTime from '$ui/components/RelativeTime.svelte';
|
||||||
import { tick } from 'svelte';
|
import { tick } from 'svelte';
|
||||||
import ActivityLog from '$ui/components/ActivityLog.svelte';
|
import ActivityLog from '$ui/components/ActivityLog.svelte';
|
||||||
@@ -564,8 +565,15 @@
|
|||||||
)}>{f.relPath}</a
|
)}>{f.relPath}</a
|
||||||
>{:else}{f.relPath}{/if}</td
|
>{:else}{f.relPath}{/if}</td
|
||||||
><td>{f.kind ?? 'file'}</td><td
|
><td>{f.kind ?? 'file'}</td><td
|
||||||
>{f.releaseVersion ??
|
>{artifactVersionLabel(
|
||||||
(f.version ? `v${f.version}` : '—')}</td
|
f.version
|
||||||
|
? {
|
||||||
|
version: f.version,
|
||||||
|
release_version: f.releaseVersion
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
f.kind ?? undefined
|
||||||
|
)}</td
|
||||||
><td
|
><td
|
||||||
><details>
|
><details>
|
||||||
<summary
|
<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 { createScript } = await import('../../src/lib/server/fleet/scripts.js');
|
||||||
const { db } = await import('../../src/lib/server/db/index.js');
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
const { script, version } = createScript({
|
const { script, version } = createScript({
|
||||||
|
releaseVersion: '1.0.0',
|
||||||
name: 'Protected beta',
|
name: 'Protected beta',
|
||||||
targetRelPath: 'protected.gsc',
|
targetRelPath: 'protected.gsc',
|
||||||
scopeType: 'game',
|
scopeType: 'game',
|
||||||
@@ -171,6 +172,7 @@ describe('active beta destruction guard', () => {
|
|||||||
expect(queued.ok).toBe(true);
|
expect(queued.ok).toBe(true);
|
||||||
if (!queued.ok) throw new Error('Expected queued removal');
|
if (!queued.ok) throw new Error('Expected queued removal');
|
||||||
const { script, version } = createScript({
|
const { script, version } = createScript({
|
||||||
|
releaseVersion: '1.0.0',
|
||||||
name: 'Beta admission',
|
name: 'Beta admission',
|
||||||
targetRelPath: 'admission.gsc',
|
targetRelPath: 'admission.gsc',
|
||||||
scopeType: 'game',
|
scopeType: 'game',
|
||||||
@@ -221,6 +223,7 @@ describe('active beta destruction guard', () => {
|
|||||||
.prepare('UPDATE server_deployments SET state = ?, stage = ? WHERE id = ?')
|
.prepare('UPDATE server_deployments SET state = ?, stage = ? WHERE id = ?')
|
||||||
.run(state, stage, operation);
|
.run(state, stage, operation);
|
||||||
const { script, version } = createScript({
|
const { script, version } = createScript({
|
||||||
|
releaseVersion: '1.0.0',
|
||||||
name: 'Beta admission',
|
name: 'Beta admission',
|
||||||
targetRelPath: 'admission.gsc',
|
targetRelPath: 'admission.gsc',
|
||||||
scopeType: 'game',
|
scopeType: 'game',
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ async function seedBeta(multiple: boolean) {
|
|||||||
const games = multiple ? ['t5', 't6'] : ['t5'];
|
const games = multiple ? ['t5', 't6'] : ['t5'];
|
||||||
const scripts = games.map((game) => {
|
const scripts = games.map((game) => {
|
||||||
const { script } = createScript({
|
const { script } = createScript({
|
||||||
|
releaseVersion: '1.0.0',
|
||||||
name: `${game} test`,
|
name: `${game} test`,
|
||||||
targetRelPath: `${game}.gsc`,
|
targetRelPath: `${game}.gsc`,
|
||||||
scopeType: 'game',
|
scopeType: 'game',
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ async function setup() {
|
|||||||
const { destOfKind, configFileOf } = await import('../../src/lib/server/config.js');
|
const { destOfKind, configFileOf } = await import('../../src/lib/server/config.js');
|
||||||
const create = (game: string, path = 'shared.gsc') =>
|
const create = (game: string, path = 'shared.gsc') =>
|
||||||
library.createScript({
|
library.createScript({
|
||||||
|
releaseVersion: '1.0.0',
|
||||||
name: `${game} ${path}`,
|
name: `${game} ${path}`,
|
||||||
targetRelPath: path,
|
targetRelPath: path,
|
||||||
scopeType: 'game',
|
scopeType: 'game',
|
||||||
@@ -82,7 +83,11 @@ describe('multi-game beta bundles', () => {
|
|||||||
expect(ownerOf(t5[0], 'six.gsc')).toBeNull();
|
expect(ownerOf(t5[0], 'six.gsc')).toBeNull();
|
||||||
await activateFiles('beta', betaId);
|
await activateFiles('beta', betaId);
|
||||||
await x.reviseBeta(betaId, 1, [
|
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(
|
expect(
|
||||||
x.stagedFileChanges('beta', String(betaId)).map((row) => [row.server, row.rel_path])
|
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');
|
await import('../../src/lib/server/fleet/inventory.js');
|
||||||
const files = [x.create('t5'), x.create('t6')];
|
const files = [x.create('t5'), x.create('t6')];
|
||||||
const priors = files.map((file) =>
|
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++) {
|
for (let i = 0; i < files.length; i++) {
|
||||||
x.db
|
x.db
|
||||||
@@ -221,6 +231,7 @@ describe('multi-game beta bundles', () => {
|
|||||||
it('applies unscoped files to every selected server alongside game-specific files', async () => {
|
it('applies unscoped files to every selected server alongside game-specific files', async () => {
|
||||||
const x = await setup();
|
const x = await setup();
|
||||||
const shared = x.createScript({
|
const shared = x.createScript({
|
||||||
|
releaseVersion: '1.0.0',
|
||||||
name: 'Shared helper',
|
name: 'Shared helper',
|
||||||
targetRelPath: 'helper.gsc',
|
targetRelPath: 'helper.gsc',
|
||||||
content: Buffer.from('shared')
|
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, six], [t5[0]])).rejects.toMatchObject({ kind: 'out-of-scope' });
|
||||||
await expect(x.open([five], participants)).rejects.toMatchObject({ kind: 'out-of-scope' });
|
await expect(x.open([five], participants)).rejects.toMatchObject({ kind: 'out-of-scope' });
|
||||||
const overlap = x.createScript({
|
const overlap = x.createScript({
|
||||||
|
releaseVersion: '1.0.0',
|
||||||
name: 'Map overlap',
|
name: 'Map overlap',
|
||||||
targetRelPath: 'shared.gsc',
|
targetRelPath: 'shared.gsc',
|
||||||
scopeType: 'map',
|
scopeType: 'map',
|
||||||
@@ -260,6 +272,7 @@ describe('multi-game beta bundles', () => {
|
|||||||
kind: 'duplicate-target'
|
kind: 'duplicate-target'
|
||||||
});
|
});
|
||||||
const global = x.createScript({
|
const global = x.createScript({
|
||||||
|
releaseVersion: '1.0.0',
|
||||||
name: 'Global overlap',
|
name: 'Global overlap',
|
||||||
targetRelPath: 'shared.gsc',
|
targetRelPath: 'shared.gsc',
|
||||||
content: Buffer.from('overlap')
|
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 { openBeta } = await import('../../src/lib/server/fleet/beta.js');
|
||||||
const { db } = await import('../../src/lib/server/db/index.js');
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
const { script } = createScript({
|
const { script } = createScript({
|
||||||
|
releaseVersion: '1.0.0',
|
||||||
name: 'Beta test',
|
name: 'Beta test',
|
||||||
targetRelPath: 'test.gsc',
|
targetRelPath: 'test.gsc',
|
||||||
content: Buffer.from('main() {}')
|
content: Buffer.from('main() {}')
|
||||||
@@ -148,6 +149,7 @@ describe('durable beta test plans', () => {
|
|||||||
fileScope === 'game' ? otherGame : otherMap
|
fileScope === 'game' ? otherGame : otherMap
|
||||||
].map((scopeValue) =>
|
].map((scopeValue) =>
|
||||||
createScript({
|
createScript({
|
||||||
|
releaseVersion: '1.0.0',
|
||||||
name: `Test ${scopeValue}`,
|
name: `Test ${scopeValue}`,
|
||||||
targetRelPath: `${scopeValue}.gsc`,
|
targetRelPath: `${scopeValue}.gsc`,
|
||||||
scopeType: fileScope,
|
scopeType: fileScope,
|
||||||
@@ -218,7 +220,11 @@ describe('durable beta test plans', () => {
|
|||||||
const file = listBetaScripts(id)[0];
|
const file = listBetaScripts(id)[0];
|
||||||
expect(getBeta(id)?.state).toBe('ready-promote');
|
expect(getBeta(id)?.state).toBe('ready-promote');
|
||||||
await reviseBeta(id, 1, [
|
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;
|
const replacement = listBetaScripts(id)[0].script_version_id;
|
||||||
expect(replacement).not.toBe(file.script_version_id);
|
expect(replacement).not.toBe(file.script_version_id);
|
||||||
@@ -613,6 +619,7 @@ describe('durable beta test plans', () => {
|
|||||||
const { openBeta, getBeta, listBetaServers } =
|
const { openBeta, getBeta, listBetaServers } =
|
||||||
await import('../../src/lib/server/fleet/beta.js');
|
await import('../../src/lib/server/fleet/beta.js');
|
||||||
const { script, version } = createScript({
|
const { script, version } = createScript({
|
||||||
|
releaseVersion: '1.0.0',
|
||||||
name: 'Legacy beta',
|
name: 'Legacy beta',
|
||||||
targetRelPath: 'legacy.gsc',
|
targetRelPath: 'legacy.gsc',
|
||||||
content: Buffer.from('main() {}')
|
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 { openBeta } = await import('../../src/lib/server/fleet/beta.js');
|
||||||
const { db } = await import('../../src/lib/server/db/index.js');
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
const { script } = createScript({
|
const { script } = createScript({
|
||||||
|
releaseVersion: '1.0.0',
|
||||||
name: 'Review test',
|
name: 'Review test',
|
||||||
kind,
|
kind,
|
||||||
targetRelPath: kind === 'script' ? 'review.gsc' : undefined,
|
targetRelPath: kind === 'script' ? 'review.gsc' : undefined,
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ async function setup(
|
|||||||
await writeFile(path, options.original);
|
await writeFile(path, options.original);
|
||||||
}
|
}
|
||||||
const { script, version } = scripts.createScript({
|
const { script, version } = scripts.createScript({
|
||||||
|
releaseVersion: '1.0.0',
|
||||||
name: 'Revision test',
|
name: 'Revision test',
|
||||||
targetRelPath: kind === 'script' ? 'revision.gsc' : undefined,
|
targetRelPath: kind === 'script' ? 'revision.gsc' : undefined,
|
||||||
kind,
|
kind,
|
||||||
@@ -89,7 +90,13 @@ describe('beta revisions', () => {
|
|||||||
x.reviseBeta(
|
x.reviseBeta(
|
||||||
x.betaId,
|
x.betaId,
|
||||||
1,
|
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'
|
'Small fix'
|
||||||
)
|
)
|
||||||
).resolves.toEqual({ revision: 2 });
|
).resolves.toEqual({ revision: 2 });
|
||||||
@@ -132,7 +139,11 @@ describe('beta revisions', () => {
|
|||||||
await activateFiles('beta', x.betaId);
|
await activateFiles('beta', x.betaId);
|
||||||
for (let revision = 2; revision <= 3; revision++) {
|
for (let revision = 2; revision <= 3; revision++) {
|
||||||
await x.reviseBeta(x.betaId, revision - 1, [
|
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);
|
await activateFiles('beta', x.betaId);
|
||||||
}
|
}
|
||||||
@@ -157,7 +168,11 @@ describe('beta revisions', () => {
|
|||||||
await activateFiles('beta', x.betaId);
|
await activateFiles('beta', x.betaId);
|
||||||
}
|
}
|
||||||
await x.reviseBeta(x.betaId, 1, [
|
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)
|
if (waves)
|
||||||
expect(x.listBetaServers(x.betaId).map((row) => row.stage)).toEqual([
|
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 () => {
|
it('does not restore or delete files when no revision was ever activated', async () => {
|
||||||
const x = await setup({ original: 'untouched' });
|
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 x.rollbackBeta(x.betaId)).toMatchObject({ deploymentId: null });
|
||||||
expect(await readFile(x.path, 'utf8')).toBe('untouched');
|
expect(await readFile(x.path, 'utf8')).toBe('untouched');
|
||||||
});
|
});
|
||||||
@@ -183,11 +200,16 @@ describe('beta revisions', () => {
|
|||||||
const x = await setup({ original: 'tracked original' });
|
const x = await setup({ original: 'tracked original' });
|
||||||
const { recordScript, getServerScript } =
|
const { recordScript, getServerScript } =
|
||||||
await import('../../src/lib/server/fleet/inventory.js');
|
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');
|
recordScript(names[0], 'revision.gsc', prior.id, prior.sha256, prior.size, 'test');
|
||||||
await activateFiles('beta', x.betaId);
|
await activateFiles('beta', x.betaId);
|
||||||
await x.reviseBeta(x.betaId, 1, [
|
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);
|
await activateFiles('beta', x.betaId);
|
||||||
expect(x.db.prepare('SELECT prior_version_id FROM beta_server_scripts').get()).toEqual({
|
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);
|
x.db.prepare("UPDATE beta_test_plans SET outcome = 'paused' WHERE beta_id = ?").run(betaId);
|
||||||
await x.reviseBeta(betaId, 1, [
|
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([
|
expect(x.listBetaServers(betaId).map((row) => [row.server, row.stage])).toEqual([
|
||||||
[names[0], 'queued'],
|
[names[0], 'queued'],
|
||||||
@@ -229,7 +251,11 @@ describe('beta revisions', () => {
|
|||||||
releaseRestartRequest(names[0], { kind: 'beta', id: String(x.betaId) });
|
releaseRestartRequest(names[0], { kind: 'beta', id: String(x.betaId) });
|
||||||
await expect(
|
await expect(
|
||||||
x.reviseBeta(x.betaId, 1, [
|
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 });
|
).resolves.toEqual({ revision: 2 });
|
||||||
});
|
});
|
||||||
@@ -238,7 +264,11 @@ describe('beta revisions', () => {
|
|||||||
const x = await setup({ original: 'original config', kind: 'baseline' });
|
const x = await setup({ original: 'original config', kind: 'baseline' });
|
||||||
await activateFiles('beta', x.betaId);
|
await activateFiles('beta', x.betaId);
|
||||||
await x.reviseBeta(x.betaId, 1, [
|
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);
|
await activateFiles('beta', x.betaId);
|
||||||
expect(await readFile(x.path, 'utf8')).toContain('set sv_voice "0"');
|
expect(await readFile(x.path, 'utf8')).toContain('set sv_voice "0"');
|
||||||
@@ -259,7 +289,11 @@ describe('beta revisions', () => {
|
|||||||
'activating'
|
'activating'
|
||||||
])('rejects %s updates without saving a revision', async (reason) => {
|
])('rejects %s updates without saving a revision', async (reason) => {
|
||||||
const x = await setup();
|
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')
|
if (reason === 'identical')
|
||||||
upload.content = Buffer.from(
|
upload.content = Buffer.from(
|
||||||
(await import('../../src/lib/server/fleet/scripts.js')).getVersionContent(
|
(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 betas SET opened_at = ?').run('2000-01-01T00:00:00Z');
|
||||||
x.db.prepare("UPDATE beta_test_plans SET outcome = 'inconclusive'").run();
|
x.db.prepare("UPDATE beta_test_plans SET outcome = 'inconclusive'").run();
|
||||||
await x.reviseBeta(x.betaId, 1, [
|
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');
|
const { closeDb } = await import('../../src/lib/server/db/index.js');
|
||||||
closeDb();
|
closeDb();
|
||||||
@@ -320,7 +354,7 @@ describe('beta revisions', () => {
|
|||||||
const x = await setup({ legacy: true });
|
const x = await setup({ legacy: true });
|
||||||
await activateFiles('beta', x.betaId);
|
await activateFiles('beta', x.betaId);
|
||||||
await x.reviseBeta(x.betaId, 1, [
|
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({
|
expect(x.listBetaServers(x.betaId)[0]).toMatchObject({
|
||||||
stage: 'awaiting-empty',
|
stage: 'awaiting-empty',
|
||||||
@@ -346,8 +380,16 @@ describe('beta revisions', () => {
|
|||||||
.run(x.betaId, overlay.script.id, overlay.version.id);
|
.run(x.betaId, overlay.script.id, overlay.version.id);
|
||||||
await expect(
|
await expect(
|
||||||
x.reviseBeta(x.betaId, 1, [
|
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');
|
).rejects.toThrow('at least one');
|
||||||
expect(x.listVersions(x.script.id)).toHaveLength(1);
|
expect(x.listVersions(x.script.id)).toHaveLength(1);
|
||||||
@@ -365,6 +407,7 @@ describe('beta revisions', () => {
|
|||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.set('expectedRevision', '1');
|
form.set('expectedRevision', '1');
|
||||||
form.set('notes', 'Fix scoring');
|
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'));
|
form.set(`file:${x.script.id}`, new File(['changed'], 'revision.gsc'));
|
||||||
const post = () =>
|
const post = () =>
|
||||||
POST({
|
POST({
|
||||||
@@ -380,4 +423,25 @@ describe('beta revisions', () => {
|
|||||||
});
|
});
|
||||||
expect(await download.text()).toBe('revision one');
|
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') {
|
async function file(scopeType: 'game' | 'map' = 'game', scopeValue = 't5') {
|
||||||
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
|
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
|
||||||
return createScript({
|
return createScript({
|
||||||
|
releaseVersion: '1.0.0',
|
||||||
name: `Test ${scopeValue}`,
|
name: `Test ${scopeValue}`,
|
||||||
targetRelPath: `${scopeValue}.gsc`,
|
targetRelPath: `${scopeValue}.gsc`,
|
||||||
scopeType,
|
scopeType,
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ async function open(kind: 'script' | 'plugin' | 'baseline' = 'script') {
|
|||||||
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
|
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
|
||||||
const { openBeta } = await import('../../src/lib/server/fleet/beta.js');
|
const { openBeta } = await import('../../src/lib/server/fleet/beta.js');
|
||||||
const { script, version } = createScript({
|
const { script, version } = createScript({
|
||||||
|
releaseVersion: '1.0.0',
|
||||||
name: `Staged ${kind}`,
|
name: `Staged ${kind}`,
|
||||||
kind,
|
kind,
|
||||||
targetRelPath:
|
targetRelPath:
|
||||||
@@ -274,7 +275,12 @@ describe('staged beta files', () => {
|
|||||||
)
|
)
|
||||||
.run(promotion.deploymentId);
|
.run(promotion.deploymentId);
|
||||||
const { uploadNewVersion } = await import('../../src/lib/server/fleet/scripts.js');
|
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({
|
const request = openBeta({
|
||||||
scripts: [{ scriptId: script.id, scriptVersionId: newer.id }],
|
scripts: [{ scriptId: script.id, scriptVersionId: newer.id }],
|
||||||
servers: [servers[1]],
|
servers: [servers[1]],
|
||||||
@@ -296,7 +302,7 @@ describe('staged beta files', () => {
|
|||||||
const { betaId: olderBeta, script } = await open(kind);
|
const { betaId: olderBeta, script } = await open(kind);
|
||||||
const { uploadNewVersion } = await import('../../src/lib/server/fleet/scripts.js');
|
const { uploadNewVersion } = await import('../../src/lib/server/fleet/scripts.js');
|
||||||
const newestBytes = kind === 'script' ? 'newest tested bytes' : 'set sv_voice "0"\n';
|
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 { openBeta, promoteBeta } = await import('../../src/lib/server/fleet/beta.js');
|
||||||
const { betaId: newerBeta } = await openBeta({
|
const { betaId: newerBeta } = await openBeta({
|
||||||
scripts: [{ scriptId: script.id, scriptVersionId: newer.id }],
|
scripts: [{ scriptId: script.id, scriptVersionId: newer.id }],
|
||||||
|
|||||||
@@ -149,6 +149,7 @@ describe('config workbench API', () => {
|
|||||||
content: Buffer.from('set b 1')
|
content: Buffer.from('set b 1')
|
||||||
});
|
});
|
||||||
const script = createScript({
|
const script = createScript({
|
||||||
|
releaseVersion: '1.0.0',
|
||||||
name: 'Script',
|
name: 'Script',
|
||||||
targetRelPath: 'test.gsc',
|
targetRelPath: 'test.gsc',
|
||||||
content: Buffer.from('code')
|
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 { createScript } = await import('../../src/lib/server/fleet/scripts.js');
|
||||||
const { openBeta } = await import('../../src/lib/server/fleet/beta.js');
|
const { openBeta } = await import('../../src/lib/server/fleet/beta.js');
|
||||||
const { script, version } = createScript({
|
const { script, version } = createScript({
|
||||||
|
releaseVersion: '1.0.0',
|
||||||
name: 'Retained history',
|
name: 'Retained history',
|
||||||
targetRelPath: 'history.gsc',
|
targetRelPath: 'history.gsc',
|
||||||
scopeType: 'game',
|
scopeType: 'game',
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ describe('multi-server blueprints', () => {
|
|||||||
const { previewCreation } = await import('../../src/lib/server/fleet/create.js');
|
const { previewCreation } = await import('../../src/lib/server/fleet/create.js');
|
||||||
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
|
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
|
||||||
const artifact = createScript({
|
const artifact = createScript({
|
||||||
|
releaseVersion: '1.0.0',
|
||||||
name: 'Telemetry',
|
name: 'Telemetry',
|
||||||
targetRelPath: 'telemetry.gsc',
|
targetRelPath: 'telemetry.gsc',
|
||||||
scopeType: 'game',
|
scopeType: 'game',
|
||||||
@@ -107,7 +108,12 @@ describe('multi-server blueprints', () => {
|
|||||||
'INSERT INTO server_blueprints (id,name,created_at,recipe) VALUES (?, ?, ?, ?)'
|
'INSERT INTO server_blueprints (id,name,created_at,recipe) VALUES (?, ?, ?, ?)'
|
||||||
)
|
)
|
||||||
.run('legacy', 'Old favorite', new Date().toISOString(), JSON.stringify(recipe));
|
.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()
|
db()
|
||||||
.prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?')
|
.prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?')
|
||||||
.run(current.id, artifact.script.id);
|
.run(current.id, artifact.script.id);
|
||||||
|
|||||||
@@ -255,6 +255,7 @@ describe('drift scanning', () => {
|
|||||||
const server = 't5_zombie_theater_1';
|
const server = 't5_zombie_theater_1';
|
||||||
const relPath = kind === 'script' ? 'outdated.gsc' : 'outdated.dll';
|
const relPath = kind === 'script' ? 'outdated.gsc' : 'outdated.dll';
|
||||||
const { script, version } = createScript({
|
const { script, version } = createScript({
|
||||||
|
releaseVersion: '1.0.0',
|
||||||
name: `outdated ${kind}`,
|
name: `outdated ${kind}`,
|
||||||
kind,
|
kind,
|
||||||
scopeType: 'game',
|
scopeType: 'game',
|
||||||
@@ -295,6 +296,7 @@ describe('drift scanning', () => {
|
|||||||
const relPath = kind === 'script' ? 'managed.gsc' : 'managed.dll';
|
const relPath = kind === 'script' ? 'managed.gsc' : 'managed.dll';
|
||||||
const content = Buffer.from('promoted content');
|
const content = Buffer.from('promoted content');
|
||||||
const { script, version } = createScript({
|
const { script, version } = createScript({
|
||||||
|
releaseVersion: '1.0.0',
|
||||||
name: `managed ${kind}`,
|
name: `managed ${kind}`,
|
||||||
kind,
|
kind,
|
||||||
scopeType: 'game',
|
scopeType: 'game',
|
||||||
@@ -465,6 +467,7 @@ describe('drift scanning', () => {
|
|||||||
const content = Buffer.from('main(){}\n');
|
const content = Buffer.from('main(){}\n');
|
||||||
const before = mode === 'identical' ? content : Buffer.from('old bytes');
|
const before = mode === 'identical' ? content : Buffer.from('old bytes');
|
||||||
const { script, version } = createScript({
|
const { script, version } = createScript({
|
||||||
|
releaseVersion: '1.0.0',
|
||||||
name: 'spawn fix',
|
name: 'spawn fix',
|
||||||
kind: 'script',
|
kind: 'script',
|
||||||
scopeType: 'game',
|
scopeType: 'game',
|
||||||
@@ -543,6 +546,7 @@ describe('drift rebuild campaigns', () => {
|
|||||||
await seedServerRegistry();
|
await seedServerRegistry();
|
||||||
const server = 't5_zombie_theater_1';
|
const server = 't5_zombie_theater_1';
|
||||||
const { script, version } = createScript({
|
const { script, version } = createScript({
|
||||||
|
releaseVersion: '1.0.0',
|
||||||
name: 'managed',
|
name: 'managed',
|
||||||
kind: 'script',
|
kind: 'script',
|
||||||
scopeType: 'game',
|
scopeType: 'game',
|
||||||
@@ -620,6 +624,7 @@ describe('drift rebuild campaigns', () => {
|
|||||||
// off disk so drift's "missing" pass has something to find.
|
// off disk so drift's "missing" pass has something to find.
|
||||||
async function promoteAndOrphan(
|
async function promoteAndOrphan(
|
||||||
createScript: (i: {
|
createScript: (i: {
|
||||||
|
releaseVersion?: string;
|
||||||
name: string;
|
name: string;
|
||||||
kind: 'script' | 'plugin' | 'baseline' | 'config';
|
kind: 'script' | 'plugin' | 'baseline' | 'config';
|
||||||
scopeType?: 'game' | 'map';
|
scopeType?: 'game' | 'map';
|
||||||
@@ -638,6 +643,7 @@ async function promoteAndOrphan(
|
|||||||
db: () => { prepare: (q: string) => { run: (...args: unknown[]) => unknown } }
|
db: () => { prepare: (q: string) => { run: (...args: unknown[]) => unknown } }
|
||||||
): Promise<{ scriptId: number }> {
|
): Promise<{ scriptId: number }> {
|
||||||
const created = createScript({
|
const created = createScript({
|
||||||
|
releaseVersion: '1.0.0',
|
||||||
name: 't5 hud',
|
name: 't5 hud',
|
||||||
kind: 'script',
|
kind: 'script',
|
||||||
scopeType: 'game',
|
scopeType: 'game',
|
||||||
|
|||||||
@@ -244,7 +244,12 @@ describe('GitHub DLL imports', () => {
|
|||||||
await expect(importPluginRelease({ url, assetId: 999, name: 'Utilities' })).rejects.toThrow(
|
await expect(importPluginRelease({ url, assetId: 999, name: 'Utilities' })).rejects.toThrow(
|
||||||
/no longer available/
|
/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(
|
await expect(
|
||||||
importPluginRelease({ url, assetId: 200, pluginId: script.script.id })
|
importPluginRelease({ url, assetId: 200, pluginId: script.script.id })
|
||||||
).rejects.toMatchObject({ status: 404 });
|
).rejects.toMatchObject({ status: 404 });
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ async function seedArtifacts() {
|
|||||||
content: Buffer.from('set sv_voice "1"\n')
|
content: Buffer.from('set sv_voice "1"\n')
|
||||||
});
|
});
|
||||||
const script = createScript({
|
const script = createScript({
|
||||||
|
releaseVersion: '1.0.0',
|
||||||
name: 'Classic perks',
|
name: 'Classic perks',
|
||||||
kind: 'script',
|
kind: 'script',
|
||||||
scopeType: 'game',
|
scopeType: 'game',
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ describe('scope impact preview', () => {
|
|||||||
await import('../../src/lib/server/fleet/inventory.js');
|
await import('../../src/lib/server/fleet/inventory.js');
|
||||||
const { GET } = await import('../../src/routes/api/scripts/[id]/scope/+server.js');
|
const { GET } = await import('../../src/routes/api/scripts/[id]/scope/+server.js');
|
||||||
const script = createScript({
|
const script = createScript({
|
||||||
|
releaseVersion: '1.0.0',
|
||||||
name: 'Example',
|
name: 'Example',
|
||||||
scopeType: 'game',
|
scopeType: 'game',
|
||||||
scopeValue: 't5',
|
scopeValue: 't5',
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ async function setup(capable = true) {
|
|||||||
targetRelPath?: string
|
targetRelPath?: string
|
||||||
) {
|
) {
|
||||||
const artifact = createScript({
|
const artifact = createScript({
|
||||||
|
releaseVersion: '1.0.0',
|
||||||
name,
|
name,
|
||||||
kind,
|
kind,
|
||||||
targetRelPath,
|
targetRelPath,
|
||||||
|
|||||||
@@ -275,6 +275,7 @@ describe('asynchronous native launch preparation', () => {
|
|||||||
kind === 'baseline'
|
kind === 'baseline'
|
||||||
? baseline
|
? baseline
|
||||||
: createScript({
|
: createScript({
|
||||||
|
releaseVersion: '1.0.0',
|
||||||
name: `Launch ${kind}`,
|
name: `Launch ${kind}`,
|
||||||
kind,
|
kind,
|
||||||
targetRelPath:
|
targetRelPath:
|
||||||
@@ -299,7 +300,7 @@ describe('asynchronous native launch preparation', () => {
|
|||||||
? 'set rcon_password "password"\nset sv_voice "0"\n'
|
? 'set rcon_password "password"\nset sv_voice "0"\n'
|
||||||
: 'newly promoted file'
|
: 'newly promoted file'
|
||||||
);
|
);
|
||||||
const latest = uploadNewVersion(script.id, content);
|
const latest = uploadNewVersion(script.id, content, undefined, '1.0.1');
|
||||||
db()
|
db()
|
||||||
.prepare('UPDATE scripts SET current_version_id=? WHERE id=?')
|
.prepare('UPDATE scripts SET current_version_id=? WHERE id=?')
|
||||||
.run(latest.id, script.id);
|
.run(latest.id, script.id);
|
||||||
|
|||||||
@@ -188,6 +188,7 @@ describe('rebalance instance migrations', () => {
|
|||||||
content: Buffer.from('set sv_voice "1"\n')
|
content: Buffer.from('set sv_voice "1"\n')
|
||||||
});
|
});
|
||||||
const script = createScript({
|
const script = createScript({
|
||||||
|
releaseVersion: '1.0.0',
|
||||||
name: 'Deployed script',
|
name: 'Deployed script',
|
||||||
kind: 'script',
|
kind: 'script',
|
||||||
targetRelPath: 'preserved.gsc',
|
targetRelPath: 'preserved.gsc',
|
||||||
@@ -208,7 +209,9 @@ describe('rebalance instance migrations', () => {
|
|||||||
await acknowledge(job.deploymentId);
|
await acknowledge(job.deploymentId);
|
||||||
const patched = uploadNewVersion(
|
const patched = uploadNewVersion(
|
||||||
script.script.id,
|
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');
|
const { recordScript } = await import('../../src/lib/server/fleet/inventory.js');
|
||||||
recordScript(
|
recordScript(
|
||||||
@@ -221,7 +224,9 @@ describe('rebalance instance migrations', () => {
|
|||||||
);
|
);
|
||||||
const newest = uploadNewVersion(
|
const newest = uploadNewVersion(
|
||||||
script.script.id,
|
script.script.id,
|
||||||
Buffer.from('new promotion must not be installed')
|
Buffer.from('new promotion must not be installed'),
|
||||||
|
undefined,
|
||||||
|
'1.0.2'
|
||||||
);
|
);
|
||||||
db()
|
db()
|
||||||
.prepare('UPDATE scripts SET current_version_id=? WHERE id=?')
|
.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 { reportHostOperation } = await import('../../src/lib/server/hosts/index.js');
|
||||||
const { rollbackBeta } = await import('../../src/lib/server/fleet/beta.js');
|
const { rollbackBeta } = await import('../../src/lib/server/fleet/beta.js');
|
||||||
const { script, version } = createScript({
|
const { script, version } = createScript({
|
||||||
|
releaseVersion: '1.0.0',
|
||||||
name: 'Broken beta',
|
name: 'Broken beta',
|
||||||
kind: 'script',
|
kind: 'script',
|
||||||
targetRelPath: 'beta.gsc',
|
targetRelPath: 'beta.gsc',
|
||||||
@@ -3774,6 +3780,7 @@ describe('cancelled native mutation bookkeeping', () => {
|
|||||||
const { reportHostOperation, getHostOperation } =
|
const { reportHostOperation, getHostOperation } =
|
||||||
await import('../../src/lib/server/hosts/index.js');
|
await import('../../src/lib/server/hosts/index.js');
|
||||||
const { script, version } = createScript({
|
const { script, version } = createScript({
|
||||||
|
releaseVersion: '1.0.0',
|
||||||
name: 'Revised beta',
|
name: 'Revised beta',
|
||||||
kind: 'script',
|
kind: 'script',
|
||||||
targetRelPath: 'revision.gsc',
|
targetRelPath: 'revision.gsc',
|
||||||
@@ -3804,7 +3811,11 @@ describe('cancelled native mutation bookkeeping', () => {
|
|||||||
// A dispatched activation must settle before revision submission.
|
// A dispatched activation must settle before revision submission.
|
||||||
await expect(
|
await expect(
|
||||||
reviseBeta(betaId, 1, [
|
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();
|
).rejects.toThrow();
|
||||||
const activation = (await operationFor(restart.id))!;
|
const activation = (await operationFor(restart.id))!;
|
||||||
@@ -3823,7 +3834,11 @@ describe('cancelled native mutation bookkeeping', () => {
|
|||||||
}
|
}
|
||||||
await activate(original);
|
await activate(original);
|
||||||
await reviseBeta(betaId, 1, [
|
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');
|
await activate('revision one');
|
||||||
const rollback = await rollbackBeta(betaId);
|
const rollback = await rollbackBeta(betaId);
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ describe('native disruption safety at dispatch', () => {
|
|||||||
const pending = await suspendInspection();
|
const pending = await suspendInspection();
|
||||||
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
|
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
|
||||||
const { script, version } = createScript({
|
const { script, version } = createScript({
|
||||||
|
releaseVersion: '1.0.0',
|
||||||
name: 'Race beta',
|
name: 'Race beta',
|
||||||
kind: 'script',
|
kind: 'script',
|
||||||
targetRelPath: 'beta.gsc',
|
targetRelPath: 'beta.gsc',
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ beforeEach(async () => {
|
|||||||
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
|
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
|
||||||
const { db } = await import('../../src/lib/server/db/index.js');
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
const { script, version } = createScript({
|
const { script, version } = createScript({
|
||||||
|
releaseVersion: '1.0.0',
|
||||||
name: 'HUD',
|
name: 'HUD',
|
||||||
targetRelPath: 'hud.gsc',
|
targetRelPath: 'hud.gsc',
|
||||||
content: Buffer.from('main(){}')
|
content: Buffer.from('main(){}')
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ async function promotion(kind: 'script' | 'plugin' | 'baseline', staged: boolean
|
|||||||
? 'managed.dll'
|
? 'managed.dll'
|
||||||
: 'managed.gsc';
|
: 'managed.gsc';
|
||||||
const { script, version: prior } = createScript({
|
const { script, version: prior } = createScript({
|
||||||
|
releaseVersion: '1.0.0',
|
||||||
name: 'managed',
|
name: 'managed',
|
||||||
kind,
|
kind,
|
||||||
targetRelPath: relPath,
|
targetRelPath: relPath,
|
||||||
@@ -41,7 +42,7 @@ async function promotion(kind: 'script' | 'plugin' | 'baseline', staged: boolean
|
|||||||
scopeValue: 't5',
|
scopeValue: 't5',
|
||||||
content: before
|
content: before
|
||||||
});
|
});
|
||||||
const version = uploadNewVersion(script.id, after);
|
const version = uploadNewVersion(script.id, after, undefined, '1.0.1');
|
||||||
db()
|
db()
|
||||||
.prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?')
|
.prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?')
|
||||||
.run(version.id, script.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 { db } = await import('../../src/lib/server/db/index.js');
|
||||||
const scopeValue = scopeType === 'game' ? 't5' : 'zombie_theater';
|
const scopeValue = scopeType === 'game' ? 't5' : 'zombie_theater';
|
||||||
const { script, version } = createScript({
|
const { script, version } = createScript({
|
||||||
|
releaseVersion: '1.0.0',
|
||||||
name: 'restore scope',
|
name: 'restore scope',
|
||||||
targetRelPath: 'restore.gsc',
|
targetRelPath: 'restore.gsc',
|
||||||
scopeType,
|
scopeType,
|
||||||
@@ -135,6 +136,7 @@ describe('editing promoted script scopes', () => {
|
|||||||
const { openBeta } = await import('../../src/lib/server/fleet/beta.js');
|
const { openBeta } = await import('../../src/lib/server/fleet/beta.js');
|
||||||
const { db } = await import('../../src/lib/server/db/index.js');
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
const { script, version } = createScript({
|
const { script, version } = createScript({
|
||||||
|
releaseVersion: '1.0.0',
|
||||||
name: 'active scope',
|
name: 'active scope',
|
||||||
targetRelPath: 'active.gsc',
|
targetRelPath: 'active.gsc',
|
||||||
scopeType: 'game',
|
scopeType: 'game',
|
||||||
@@ -179,6 +181,7 @@ describe('promote creates a deployment', () => {
|
|||||||
|
|
||||||
// A game-scoped script matches both fixture servers.
|
// A game-scoped script matches both fixture servers.
|
||||||
const { script } = createScript({
|
const { script } = createScript({
|
||||||
|
releaseVersion: '1.0.0',
|
||||||
name: 'hud tweaks',
|
name: 'hud tweaks',
|
||||||
targetRelPath: 'hud.gsc',
|
targetRelPath: 'hud.gsc',
|
||||||
scopeType: 'game',
|
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 { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
|
||||||
const { script } = createScript({
|
const { script } = createScript({
|
||||||
|
releaseVersion: '1.0.0',
|
||||||
name: 'flag experiment',
|
name: 'flag experiment',
|
||||||
targetRelPath: 'flag.gsc',
|
targetRelPath: 'flag.gsc',
|
||||||
scopeType: 'map',
|
scopeType: 'map',
|
||||||
@@ -310,6 +314,7 @@ describe('rollback promote deployment', () => {
|
|||||||
const { db } = await import('../../src/lib/server/db/index.js');
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
|
||||||
const { script } = createScript({
|
const { script } = createScript({
|
||||||
|
releaseVersion: '1.0.0',
|
||||||
name: 'roll me back',
|
name: 'roll me back',
|
||||||
targetRelPath: 'rb.gsc',
|
targetRelPath: 'rb.gsc',
|
||||||
scopeType: 'game',
|
scopeType: 'game',
|
||||||
@@ -379,6 +384,7 @@ describe('multi-script beta', () => {
|
|||||||
|
|
||||||
// Two scripts, both scope=game=t5 so they both land on both fixture servers.
|
// Two scripts, both scope=game=t5 so they both land on both fixture servers.
|
||||||
const a = createScript({
|
const a = createScript({
|
||||||
|
releaseVersion: '1.0.0',
|
||||||
name: 'hud',
|
name: 'hud',
|
||||||
targetRelPath: 'hud.gsc',
|
targetRelPath: 'hud.gsc',
|
||||||
scopeType: 'game',
|
scopeType: 'game',
|
||||||
@@ -386,6 +392,7 @@ describe('multi-script beta', () => {
|
|||||||
content: Buffer.from('hud(){}\n')
|
content: Buffer.from('hud(){}\n')
|
||||||
});
|
});
|
||||||
const b = createScript({
|
const b = createScript({
|
||||||
|
releaseVersion: '1.0.0',
|
||||||
name: 'flag',
|
name: 'flag',
|
||||||
targetRelPath: 'flag.gsc',
|
targetRelPath: 'flag.gsc',
|
||||||
scopeType: 'game',
|
scopeType: 'game',
|
||||||
|
|||||||
@@ -10,8 +10,9 @@ beforeEach(async () => {
|
|||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await fixture.cleanup();
|
await fixture.cleanup();
|
||||||
});
|
});
|
||||||
function request(content: string, reuse = false) {
|
function request(content: string, reuse = false, releaseVersion = '1.0.0') {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
|
form.append('releaseVersion', releaseVersion);
|
||||||
form.append('content', new File([content], 'hud.gsc'));
|
form.append('content', new File([content], 'hud.gsc'));
|
||||||
if (reuse) form.append('reuseExisting', 'true');
|
if (reuse) form.append('reuseExisting', 'true');
|
||||||
return new Request('http://localhost/api/scripts/inspect', { method: 'POST', body: form });
|
return new Request('http://localhost/api/scripts/inspect', { method: 'POST', body: form });
|
||||||
@@ -21,6 +22,7 @@ async function seed() {
|
|||||||
return {
|
return {
|
||||||
...mod,
|
...mod,
|
||||||
...mod.createScript({
|
...mod.createScript({
|
||||||
|
releaseVersion: '1.0.0',
|
||||||
name: 'HUD',
|
name: 'HUD',
|
||||||
targetRelPath: 'hud.gsc',
|
targetRelPath: 'hud.gsc',
|
||||||
scopeType: 'game',
|
scopeType: 'game',
|
||||||
@@ -33,7 +35,7 @@ async function seed() {
|
|||||||
describe('script upload review', () => {
|
describe('script upload review', () => {
|
||||||
it('inspects content against all versions without creating records or including plugins', async () => {
|
it('inspects content against all versions without creating records or including plugins', async () => {
|
||||||
const { script, version, uploadNewVersion, createScript, listVersions } = await seed();
|
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({
|
createScript({
|
||||||
name: 'Plugin',
|
name: 'Plugin',
|
||||||
targetRelPath: 'plugin.dll',
|
targetRelPath: 'plugin.dll',
|
||||||
@@ -44,17 +46,19 @@ describe('script upload review', () => {
|
|||||||
const res = await POST({ request: request('main(){}') });
|
const res = await POST({ request: request('main(){}') });
|
||||||
expect(await res.json()).toEqual({
|
expect(await res.json()).toEqual({
|
||||||
sha256: version.sha256,
|
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(listVersions(script.id)).toHaveLength(2);
|
||||||
expect((await POST({ request: request('') })).status).toBe(400);
|
expect((await POST({ request: request('') })).status).toBe(400);
|
||||||
});
|
});
|
||||||
it('reuses identical historical content and creates changed versions while preserving scope', async () => {
|
it('reuses identical historical content and creates changed versions while preserving scope', async () => {
|
||||||
const { script, version, uploadNewVersion, listVersions, getScript } = await seed();
|
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 { POST } = await import('../../src/routes/api/scripts/[id]/versions/+server.js');
|
||||||
const invoke = (content: string) =>
|
const invoke = (content: string, release = '1.0.0') =>
|
||||||
POST({ params: { id: String(script.id) }, request: request(content, true) });
|
POST({ params: { id: String(script.id) }, request: request(content, true, release) });
|
||||||
const res = await invoke('main(){}');
|
const res = await invoke('main(){}');
|
||||||
expect(await res.json()).toMatchObject({
|
expect(await res.json()).toMatchObject({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -62,7 +66,7 @@ describe('script upload review', () => {
|
|||||||
version: { id: version.id, version: 1 }
|
version: { id: version.id, version: 1 }
|
||||||
});
|
});
|
||||||
expect(listVersions(script.id)).toHaveLength(2);
|
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(await added.json()).toMatchObject({ ok: true, version: { version: 3 } });
|
||||||
expect(listVersions(script.id)).toHaveLength(3);
|
expect(listVersions(script.id)).toHaveLength(3);
|
||||||
expect(getScript(script.id)).toMatchObject({ scope_type: 'game', scope_value: 't5' });
|
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 () => {
|
it('lists promoted and latest version metadata separately without file contents', async () => {
|
||||||
const { script, version, uploadNewVersion } = await seed();
|
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');
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
db()
|
db()
|
||||||
.prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?')
|
.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({
|
const { script, version } = createScript({
|
||||||
name: 'Plugin',
|
name: 'Plugin',
|
||||||
kind: 'plugin',
|
kind: 'plugin',
|
||||||
releaseVersion: 'v1.0.0',
|
releaseVersion: '1.0.0',
|
||||||
targetRelPath: 'native.bin',
|
targetRelPath: 'native.bin',
|
||||||
content: Buffer.from('plugin')
|
content: Buffer.from('plugin')
|
||||||
});
|
});
|
||||||
@@ -96,7 +96,7 @@ describe('server detail data', () => {
|
|||||||
).toMatchObject({
|
).toMatchObject({
|
||||||
artifactId: script.id,
|
artifactId: script.id,
|
||||||
version: version.version,
|
version: version.version,
|
||||||
releaseVersion: 'v1.0.0',
|
releaseVersion: '1.0.0',
|
||||||
sha256: version.sha256
|
sha256: version.sha256
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -113,7 +113,7 @@ describe('server detail data', () => {
|
|||||||
const { version } = createScript({
|
const { version } = createScript({
|
||||||
name: 'Plugin',
|
name: 'Plugin',
|
||||||
kind: 'plugin',
|
kind: 'plugin',
|
||||||
releaseVersion: 'v1.0.0',
|
releaseVersion: '1.0.0',
|
||||||
targetRelPath: path,
|
targetRelPath: path,
|
||||||
content: Buffer.from('plugin')
|
content: Buffer.from('plugin')
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ async function queueChange(source: Source, targets = [servers[0]]) {
|
|||||||
const { recordScript, getServerScript } =
|
const { recordScript, getServerScript } =
|
||||||
await import('../../src/lib/server/fleet/inventory.js');
|
await import('../../src/lib/server/fleet/inventory.js');
|
||||||
const original = createScript({
|
const original = createScript({
|
||||||
|
releaseVersion: '1.0.0',
|
||||||
name: 'Staged script',
|
name: 'Staged script',
|
||||||
kind: 'script',
|
kind: 'script',
|
||||||
targetRelPath: relPath,
|
targetRelPath: relPath,
|
||||||
@@ -67,7 +68,7 @@ async function queueChange(source: Source, targets = [servers[0]]) {
|
|||||||
scopeValue: 't5',
|
scopeValue: 't5',
|
||||||
content: before
|
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) {
|
for (const server of targets) {
|
||||||
await writeFile(destination(server), before);
|
await writeFile(destination(server), before);
|
||||||
recordScript(
|
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).toContain('Host report needs review');
|
||||||
expect(body).not.toContain('Ready hosts');
|
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 { createScript } = await import('../../src/lib/server/fleet/scripts.js');
|
||||||
const { openBeta } = await import('../../src/lib/server/fleet/beta.js');
|
const { openBeta } = await import('../../src/lib/server/fleet/beta.js');
|
||||||
const { script, version } = createScript({
|
const { script, version } = createScript({
|
||||||
|
releaseVersion: '1.0.0',
|
||||||
name: 'beta',
|
name: 'beta',
|
||||||
content: Buffer.from('experimental'),
|
content: Buffer.from('experimental'),
|
||||||
targetRelPath: 'beta.gsc'
|
targetRelPath: 'beta.gsc'
|
||||||
|
|||||||
Reference in New Issue
Block a user