Refresh script library with batch uploads and beta handoff
This commit is contained in:
@@ -0,0 +1,55 @@
|
|||||||
|
import { T5_ZOMBIE_MAPS, T6_ZOMBIE_MAPS } from './map-aliases.js';
|
||||||
|
|
||||||
|
export interface LibraryVersion {
|
||||||
|
id: number; version: number; sha256: string; size: number;
|
||||||
|
state: 'untested' | 'tested' | 'test-failed'; uploaded_at: string;
|
||||||
|
}
|
||||||
|
export interface LibraryScript {
|
||||||
|
id: number; name: string; target_rel_path: string;
|
||||||
|
scope_type: 'game' | 'map' | null; scope_value: string | null;
|
||||||
|
current_version_id: number | null;
|
||||||
|
currentVersion: LibraryVersion | null; latestVersion: LibraryVersion | null;
|
||||||
|
versionCount: number; servers: string[]; runningBetaId: number | null;
|
||||||
|
}
|
||||||
|
export type ScriptStatus = 'untested' | 'testing' | 'tested' | 'failed' | 'promoted' | 'empty';
|
||||||
|
export const statusLabels: Record<ScriptStatus, string> = {
|
||||||
|
untested: 'Needs testing', testing: 'Beta active', tested: 'Test passed',
|
||||||
|
failed: 'Test failed', promoted: 'Promoted', empty: 'No versions'
|
||||||
|
};
|
||||||
|
export function scriptStatus(s: LibraryScript): ScriptStatus {
|
||||||
|
if (s.runningBetaId !== null) return 'testing';
|
||||||
|
if (!s.latestVersion) return 'empty';
|
||||||
|
if (s.latestVersion.id === s.current_version_id) return 'promoted';
|
||||||
|
return s.latestVersion.state === 'test-failed' ? 'failed' : s.latestVersion.state;
|
||||||
|
}
|
||||||
|
export function canTest(s: LibraryScript): boolean {
|
||||||
|
return s.runningBetaId === null && s.latestVersion !== null && s.latestVersion.id !== s.current_version_id;
|
||||||
|
}
|
||||||
|
export function mapLabel(canonical: string): string {
|
||||||
|
const matches = [...T5_ZOMBIE_MAPS, ...T6_ZOMBIE_MAPS].filter(m => m.canonical === canonical);
|
||||||
|
return matches.length > 1 ? `${matches[0].friendly} + ${matches.length - 1} variants` : matches[0]?.friendly ?? canonical;
|
||||||
|
}
|
||||||
|
export function scopeLabel(s: Pick<LibraryScript, 'scope_type' | 'scope_value'>): string {
|
||||||
|
return s.scope_type === 'game' ? (s.scope_value ?? '').toUpperCase()
|
||||||
|
: s.scope_type === 'map' ? mapLabel(s.scope_value ?? '') : 'Unscoped';
|
||||||
|
}
|
||||||
|
export interface BetaSelection { scriptId: number; versionId: number; }
|
||||||
|
export function betaSelectionHref(selections: BetaSelection[]): string {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
for (const s of selections) query.append('artifact', `${s.scriptId}:${s.versionId}`);
|
||||||
|
return `/betas?${query}`;
|
||||||
|
}
|
||||||
|
export function parseBetaSelection(query: URLSearchParams): BetaSelection[] {
|
||||||
|
const values = query.getAll('artifact');
|
||||||
|
if (!values.length && query.has('pluginId')) values.push(`${query.get('pluginId')}:${query.get('versionId')}`);
|
||||||
|
const seen = new Set<number>();
|
||||||
|
return values.map(value => {
|
||||||
|
if (!/^[1-9]\d*:[1-9]\d*$/.test(value)) throw new Error('The beta selection link is invalid. Choose artifacts below.');
|
||||||
|
const [scriptId, versionId] = value.split(':').map(Number);
|
||||||
|
if (!Number.isSafeInteger(scriptId) || !Number.isSafeInteger(versionId) || seen.has(scriptId)) {
|
||||||
|
throw new Error('The beta selection link contains invalid or repeated artifacts. Choose artifacts below.');
|
||||||
|
}
|
||||||
|
seen.add(scriptId);
|
||||||
|
return { scriptId, versionId };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { tick } from 'svelte';
|
||||||
|
import Button from './primitives/Button.svelte';
|
||||||
|
import { betaSelectionHref, mapLabel, scopeLabel, type BetaSelection, type LibraryScript } from '$lib/script-library.js';
|
||||||
|
let { scripts, onuploaded }: { scripts: LibraryScript[]; onuploaded: () => Promise<void> } = $props();
|
||||||
|
interface Duplicate { scriptId: number; versionId: number; version: number; }
|
||||||
|
interface Upload {
|
||||||
|
key: number; file: File; target: number | 'new' | ''; name: string;
|
||||||
|
duplicates: Duplicate[]; inspecting: boolean; inspected: boolean; error: string;
|
||||||
|
result: (BetaSelection & { version: number; reused: boolean }) | null;
|
||||||
|
}
|
||||||
|
let dialog: HTMLDialogElement;
|
||||||
|
let input: HTMLInputElement;
|
||||||
|
let queue = $state<Upload[]>([]);
|
||||||
|
let busy = $state(false);
|
||||||
|
let notes = $state('');
|
||||||
|
let scopeType = $state<'none' | 'game' | 'map'>('none');
|
||||||
|
let scopeGame = $state('');
|
||||||
|
let scopeMap = $state('');
|
||||||
|
let scopeOptions = $state<{ games: string[]; mapsByGame: Record<string, string[]> }>({ games: [], mapsByGame: {} });
|
||||||
|
let servers = $state<Array<{ name: string; game: string | null; map: string | null }>>([]);
|
||||||
|
let scopeLoaded = $state(false);
|
||||||
|
let message = $state('');
|
||||||
|
let preferredTarget = $state<number | null>(null);
|
||||||
|
let nextKey = 0;
|
||||||
|
const pending = $derived(queue.filter(q => !q.result));
|
||||||
|
const inspecting = $derived(queue.some(q => q.inspecting));
|
||||||
|
const hasNew = $derived(pending.some(q => q.target === 'new'));
|
||||||
|
const scopeValue = $derived(scopeType === 'game' ? scopeGame : scopeType === 'map' ? scopeMap : '');
|
||||||
|
const matches = $derived(servers.filter(s => scopeType === 'game' ? s.game === scopeValue : s.map === scopeValue));
|
||||||
|
const ready = $derived(pending.length > 0 && !busy && !inspecting && pending.every(q => q.inspected && !validation(q))
|
||||||
|
&& (!hasNew || scopeType === 'none' || scopeLoaded && !!scopeValue));
|
||||||
|
const results = $derived(queue.flatMap(q => q.result ? [q.result] : []));
|
||||||
|
const testable = $derived(results.filter(r => {
|
||||||
|
const s = scripts.find(s => s.id === r.scriptId);
|
||||||
|
return s && s.runningBetaId === null && s.current_version_id !== r.versionId;
|
||||||
|
}));
|
||||||
|
|
||||||
|
export function open(scriptId?: number) {
|
||||||
|
// Reopening preserves unfinished work; a targeted upload starts a fresh review
|
||||||
|
// only when there is no pending file to lose.
|
||||||
|
if (!pending.length) { queue = []; notes = ''; message = ''; preferredTarget = scriptId ?? null; }
|
||||||
|
dialog.showModal();
|
||||||
|
if (!scopeLoaded) void loadScope();
|
||||||
|
}
|
||||||
|
async function loadScope() {
|
||||||
|
try {
|
||||||
|
const [scopeRes, serversRes] = await Promise.all([fetch('/api/scope-options'), fetch('/api/servers')]);
|
||||||
|
if (!scopeRes.ok || !serversRes.ok) throw new Error('Scope preview could not be loaded. Reopen the drawer to retry.');
|
||||||
|
scopeOptions = await scopeRes.json(); servers = (await serversRes.json()).servers;
|
||||||
|
scopeLoaded = true;
|
||||||
|
} catch (e) { message = (e as Error).message; }
|
||||||
|
}
|
||||||
|
function matchingScripts(file: File) {
|
||||||
|
return scripts.filter(s => s.target_rel_path.replaceAll('\\', '/').split('/').pop()?.toLowerCase() === file.name.toLowerCase());
|
||||||
|
}
|
||||||
|
export async function addFiles(files: File[]) {
|
||||||
|
if (busy || !files.length) return;
|
||||||
|
if (!dialog.open) open();
|
||||||
|
for (const file of files) {
|
||||||
|
const matches = matchingScripts(file);
|
||||||
|
const item: Upload = { key: nextKey++, file, target: preferredTarget ?? (matches.length === 1 ? matches[0].id : matches.length > 1 ? '' : 'new'),
|
||||||
|
name: file.name.replace(/\.[^.]+$/, ''), duplicates: [], inspecting: true, inspected: false, error: '', result: null };
|
||||||
|
queue = [...queue, item];
|
||||||
|
// Work with the reactive proxy, rather than the original object.
|
||||||
|
await inspect(queue.find(q => q.key === item.key)!);
|
||||||
|
}
|
||||||
|
preferredTarget = null;
|
||||||
|
}
|
||||||
|
async function inspect(item: Upload) {
|
||||||
|
item.inspecting = true; item.error = '';
|
||||||
|
try {
|
||||||
|
const form = new FormData(); form.append('content', item.file);
|
||||||
|
const res = await fetch('/api/scripts/inspect', { method: 'POST', body: form });
|
||||||
|
const body = await res.json();
|
||||||
|
if (!res.ok) throw new Error(body.error ?? 'Could not inspect this file.');
|
||||||
|
item.duplicates = body.duplicates; item.inspected = true;
|
||||||
|
} catch (e) { item.error = (e as Error).message; }
|
||||||
|
finally { item.inspecting = false; }
|
||||||
|
}
|
||||||
|
function duplicate(item: Upload) { return item.duplicates.find(d => d.scriptId === item.target); }
|
||||||
|
function validation(item: Upload): string {
|
||||||
|
if (item.target === '') return 'Several scripts use this filename. Choose which script to update.';
|
||||||
|
if (item.target === 'new') {
|
||||||
|
if (!item.name.trim()) return 'Give this script a name.';
|
||||||
|
if (scripts.some(s => s.name === item.name.trim())) return 'This name already exists. Choose a new name or update that script.';
|
||||||
|
if (queue.some(q => q.key !== item.key && !q.result && q.target === 'new' && q.name.trim() === item.name.trim())) return 'Use a distinct name for each new script.';
|
||||||
|
} else {
|
||||||
|
const script = scripts.find(s => s.id === item.target);
|
||||||
|
if (!script) return 'This script is no longer available.';
|
||||||
|
if (script.runningBetaId !== null) return 'Finish the active beta before uploading another version.';
|
||||||
|
if (queue.some(q => q.key !== item.key && !q.result && q.target === item.target)) return 'Choose one file per existing script in this batch.';
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
async function upload(prepareBeta: boolean) {
|
||||||
|
if (!ready) return;
|
||||||
|
busy = true; message = '';
|
||||||
|
try {
|
||||||
|
for (const item of queue.filter(q => !q.result)) {
|
||||||
|
item.error = '';
|
||||||
|
try {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append('content', item.file); form.append('notes', notes.trim());
|
||||||
|
const creating = item.target === 'new';
|
||||||
|
if (creating) {
|
||||||
|
form.append('name', item.name.trim()); form.append('scopeType', scopeType);
|
||||||
|
if (scopeValue) form.append('scopeValue', scopeValue);
|
||||||
|
} else form.append('reuseExisting', 'true');
|
||||||
|
const res = await fetch(creating ? '/api/scripts' : `/api/scripts/${item.target}/versions`, { method: 'POST', body: form });
|
||||||
|
const body = await res.json();
|
||||||
|
if (!res.ok || !body.ok) throw new Error(body.error ?? 'Upload failed. Try again.');
|
||||||
|
item.result = { scriptId: creating ? body.script.id : item.target as number,
|
||||||
|
versionId: creating ? body.versionId : body.version.id,
|
||||||
|
version: creating ? 1 : body.version.version, reused: !!body.reused };
|
||||||
|
} catch (e) { item.error = (e as Error).message; }
|
||||||
|
}
|
||||||
|
await onuploaded();
|
||||||
|
if (queue.some(q => !q.result)) message = 'Some files need attention. Successful uploads are saved; retry only the remaining files.';
|
||||||
|
else {
|
||||||
|
message = 'Your files are ready. Choose servers and a test plan in beta setup.';
|
||||||
|
// Read from refreshed props after Svelte applies the parent update.
|
||||||
|
await tick();
|
||||||
|
if (testable.length !== results.length) message = `${results.length - testable.length} version(s) are already promoted or unavailable for a new beta. Review the saved files before continuing.`;
|
||||||
|
if (prepareBeta && testable.length === results.length && testable.length) { dialog.close(); await goto(betaSelectionHref(testable)); }
|
||||||
|
}
|
||||||
|
} catch (e) { message = (e as Error).message; }
|
||||||
|
finally { busy = false; }
|
||||||
|
}
|
||||||
|
function close(event?: Event) { if (busy || inspecting) { event?.preventDefault(); return; } dialog.close(); }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<dialog bind:this={dialog} aria-labelledby="upload-title" oncancel={close}>
|
||||||
|
<div class="drawer">
|
||||||
|
<header><div><span class="eyebrow">Prepare your next release</span><h2 id="upload-title">Upload scripts</h2></div><Button variant="ghost" ariaLabel="Close upload drawer" disabled={busy || inspecting} onclick={() => close()}>✕</Button></header>
|
||||||
|
<p class="intro">Drop files anywhere or choose them below. Review each destination before uploading.</p>
|
||||||
|
<div class="dropzone">
|
||||||
|
<strong>{preferredTarget ? `New version for ${scripts.find(s => s.id === preferredTarget)?.name ?? 'script'}` : 'One file or a whole batch'}</strong>
|
||||||
|
<span>Existing filenames are recognized automatically.</span>
|
||||||
|
<Button disabled={busy || inspecting} onclick={() => input.click()}>Choose files</Button>
|
||||||
|
<input class="file-input" bind:this={input} type="file" multiple aria-label="Choose script files" onchange={event => { void addFiles([...event.currentTarget.files ?? []]); event.currentTarget.value = ''; }} />
|
||||||
|
</div>
|
||||||
|
<div class="queue" aria-live="polite">
|
||||||
|
{#each queue as item (item.key)}
|
||||||
|
<section class="upload-item" aria-label={item.file.name}>
|
||||||
|
<div class="file-heading"><strong>{item.file.name}</strong><span>{(item.file.size / 1024).toFixed(1)} KB</span>
|
||||||
|
<Button variant="ghost" size="sm" disabled={busy || inspecting} ariaLabel={`Remove ${item.file.name}`} onclick={() => queue = queue.filter(q => q.key !== item.key)}>Remove</Button>
|
||||||
|
</div>
|
||||||
|
{#if item.result}
|
||||||
|
<p class="success">{item.result.reused ? 'Already uploaded' : 'Uploaded'} as v{item.result.version} · <a href="/scripts/{item.result.scriptId}">View script →</a></p>
|
||||||
|
{:else}
|
||||||
|
<label>Destination<select bind:value={item.target} disabled={busy || item.inspecting} onchange={() => item.error = ''}>
|
||||||
|
<option value="" disabled>Choose a script…</option><option value="new">Create a new script</option>
|
||||||
|
{#each scripts as s}<option value={s.id}>{s.name} · {scopeLabel(s)}{s.runningBetaId !== null ? ' · beta active' : ''}</option>{/each}
|
||||||
|
</select></label>
|
||||||
|
{#if item.target === 'new'}<label>Script name<input bind:value={item.name} disabled={busy} /></label>
|
||||||
|
{:else if item.target !== ''}{@const s = scripts.find(s => s.id === item.target)}<p class="note">Keeps scope: {s ? scopeLabel(s) : '—'}. Target: {s?.target_rel_path}</p>{/if}
|
||||||
|
{#if item.inspecting}<p class="note">Checking file contents…</p>
|
||||||
|
{:else if duplicate(item)}<p class="success">Already uploaded as v{duplicate(item)!.version}. We’ll reuse that version.</p>
|
||||||
|
{:else if item.target !== 'new' && item.target !== ''}<p class="note">New version for {scripts.find(s => s.id === item.target)?.name}</p>{/if}
|
||||||
|
{#if validation(item)}<p class="warning">{validation(item)}</p>{/if}
|
||||||
|
{#if item.error}<p class="error" role="alert">{item.error}</p>{#if !item.inspected}<Button size="sm" disabled={busy || inspecting} onclick={() => inspect(item)}>Retry inspection</Button>{/if}{/if}
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if pending.length}
|
||||||
|
<section class="shared">
|
||||||
|
<h3>Shared upload details</h3>
|
||||||
|
<label>Version notes<textarea bind:value={notes} disabled={busy} rows="3" placeholder="What changed in these files?"></textarea></label>
|
||||||
|
{#if hasNew}
|
||||||
|
<label>Scope for new scripts<select bind:value={scopeType} disabled={busy} onchange={() => { scopeGame = ''; scopeMap = ''; }}><option value="none">Unscoped · choose beta targets</option><option value="game">Game</option><option value="map">Map</option></select></label>
|
||||||
|
{#if scopeType !== 'none'}
|
||||||
|
<label>Game<select bind:value={scopeGame} disabled={busy || !scopeLoaded} onchange={() => scopeMap = ''}><option value="">Choose a game…</option>{#each scopeOptions.games as g}<option value={g}>{g.toUpperCase()}</option>{/each}</select></label>
|
||||||
|
{#if scopeType === 'map'}<label>Map<select bind:value={scopeMap} disabled={busy || !scopeGame}><option value="">Choose a map…</option>{#each scopeOptions.mapsByGame[scopeGame] ?? [] as m}<option value={m}>{mapLabel(m)}</option>{/each}</select></label>{/if}
|
||||||
|
{#if scopeValue}<p class="note">{matches.length} matching servers: {matches.map(s => s.name).join(', ') || 'None'}</p>{/if}
|
||||||
|
{#if scopeType === 'map' && scopeGame === 't6'}<p class="note">Map scope includes every server using this engine map, including its Survival, Grief, or other variants.</p>{/if}
|
||||||
|
{:else}<p class="note">Unscoped scripts deploy only to explicitly selected beta targets.</p>{/if}
|
||||||
|
<p class="note">Existing scripts keep their current scope.</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
{#if message}<p class="notice" role="status">{message}</p>{/if}
|
||||||
|
<footer>
|
||||||
|
{#if pending.length}<Button disabled={!ready} loading={busy} onclick={() => upload(false)}>Upload{results.length ? ' remaining' : ''}</Button><Button variant="primary" disabled={!ready} onclick={() => upload(true)}>Upload & prepare beta →</Button>
|
||||||
|
{:else if testable.length}<Button variant="primary" href={betaSelectionHref(testable)}>Prepare beta with {testable.length} script{testable.length === 1 ? '' : 's'} →</Button>{/if}
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
dialog { position: fixed; inset: 0 0 0 auto; margin: 0; width: min(620px, 100vw); height: 100dvh; max-height: 100dvh; max-width: 100vw; padding: 0; border: 0; border-left: 1px solid var(--border-subtle); background: var(--surface); color: var(--text-primary); }
|
||||||
|
dialog::backdrop { background: #0009; backdrop-filter: blur(3px); }
|
||||||
|
.drawer { padding: 28px; display: flex; flex-direction: column; gap: 20px; min-height: 100%; box-sizing: border-box; }
|
||||||
|
header { display: flex; justify-content: space-between; align-items: center; gap: 16px; } h2 { margin: 6px 0 0; font-size: var(--text-2xl); } h3 { margin: 0; font-size: var(--text-md); }
|
||||||
|
.eyebrow { text-transform: uppercase; letter-spacing: .1em; color: var(--accent-primary); font-size: var(--text-xs); }
|
||||||
|
.intro, .note { color: var(--text-secondary); font-size: var(--text-sm); line-height: 1.6; margin: 0; overflow-wrap: anywhere; }
|
||||||
|
.dropzone { display: flex; flex-direction: column; align-items: center; text-align: center; gap: 12px; border: 1px dashed var(--border-strong); border-radius: var(--radius-md); padding: 28px; background: var(--surface-sunken); }
|
||||||
|
.dropzone span { font-size: var(--text-xs); color: var(--text-secondary); } .file-input { display: none; }
|
||||||
|
.queue, .shared { display: flex; flex-direction: column; gap: 16px; } .upload-item { display: flex; flex-direction: column; gap: 12px; padding: 16px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
|
||||||
|
.file-heading { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; } .file-heading strong { flex: 1; overflow-wrap: anywhere; } .file-heading span { color: var(--text-tertiary); font-size: var(--text-xs); }
|
||||||
|
label { display: flex; flex-direction: column; gap: 7px; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||||
|
input, select, textarea { box-sizing: border-box; width: 100%; min-width: 0; padding: 10px 12px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); color: var(--text-primary); background: var(--surface-sunken); font: inherit; font-size: var(--text-sm); } textarea { resize: vertical; }
|
||||||
|
input:focus-visible, select:focus-visible, textarea:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
|
||||||
|
.success, .warning, .error { margin: 0; font-size: var(--text-sm); line-height: 1.5; } .success { color: var(--state-online); } .warning { color: var(--state-warning); } .error { color: var(--state-danger); } a { color: inherit; }
|
||||||
|
.notice { padding: 14px; background: var(--surface-raised); border-radius: var(--radius-md); font-size: var(--text-sm); line-height: 1.6; }
|
||||||
|
footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; margin-top: auto; padding-top: 20px; border-top: 1px solid var(--border-subtle); }
|
||||||
|
@media (max-width: 640px) { .drawer { padding: 20px; } }
|
||||||
|
</style>
|
||||||
@@ -36,8 +36,13 @@ export async function GET({ url }: { url: URL }) {
|
|||||||
? serversMatchingScope(s.scope_type as ScopeType, s.scope_value)
|
? serversMatchingScope(s.scope_type as ScopeType, s.scope_value)
|
||||||
: [];
|
: [];
|
||||||
const runningBeta = findRunningBetaForScript(s.id);
|
const runningBeta = findRunningBetaForScript(s.id);
|
||||||
|
const current = versions.find(v => v.id === s.current_version_id);
|
||||||
return {
|
return {
|
||||||
...s,
|
...s,
|
||||||
|
currentVersion: current ? {
|
||||||
|
id: current.id, version: current.version, sha256: current.sha256,
|
||||||
|
size: current.size, state: current.state, uploaded_at: current.uploaded_at
|
||||||
|
} : null,
|
||||||
latestVersion: versions[0]
|
latestVersion: versions[0]
|
||||||
? {
|
? {
|
||||||
id: versions[0].id,
|
id: versions[0].id,
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { uploadNewVersion, getScript, ScriptError } from '$lib/server/fleet/scripts.js';
|
import { uploadNewVersion, getScript, ScriptError } from '$lib/server/fleet/scripts.js';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { db } from '$lib/server/db/index.js';
|
||||||
|
import { findRunningBetaForScript } from '$lib/server/fleet/beta.js';
|
||||||
|
|
||||||
function parseId(idRaw: string): number | null {
|
function parseId(idRaw: string): number | null {
|
||||||
const n = Number.parseInt(idRaw, 10);
|
const n = Number.parseInt(idRaw, 10);
|
||||||
@@ -21,6 +24,15 @@ export async function POST({ params, request }: { params: { id: string }; reques
|
|||||||
if (!(file instanceof File)) return json({ ok: false, error: 'content file is required' }, { status: 400 });
|
if (!(file instanceof File)) return json({ ok: false, error: 'content file is required' }, { status: 400 });
|
||||||
|
|
||||||
const content = Buffer.from(await file.arrayBuffer());
|
const content = Buffer.from(await file.arrayBuffer());
|
||||||
|
if (script.kind === 'script' && form.get('reuseExisting') === 'true') {
|
||||||
|
if (!content.length) return json({ ok: false, error: 'Choose a non-empty script file.' }, { status: 400 });
|
||||||
|
if (findRunningBetaForScript(scriptId)) {
|
||||||
|
return json({ ok: false, error: 'Finish the active beta before uploading a version.' }, { 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') ? String(form.get('releaseVersion')) : undefined;
|
const releaseVersion = form.has('releaseVersion') ? String(form.get('releaseVersion')) : undefined;
|
||||||
const version = uploadNewVersion(scriptId, content, notes, releaseVersion);
|
const version = uploadNewVersion(scriptId, content, notes, releaseVersion);
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { db } from '$lib/server/db/index.js';
|
||||||
|
|
||||||
|
// Read-only inspection works on HTTP fleet consoles as well as secure origins.
|
||||||
|
export async function POST({ request }: { request: Request }) {
|
||||||
|
const form = await request.formData();
|
||||||
|
const file = form.get('content');
|
||||||
|
if (!(file instanceof File) || file.size === 0) {
|
||||||
|
return json({ error: 'Choose a non-empty script file.' }, { status: 400 });
|
||||||
|
}
|
||||||
|
const sha256 = createHash('sha256').update(Buffer.from(await file.arrayBuffer())).digest('hex');
|
||||||
|
const duplicates = db().prepare(`SELECT v.script_id AS scriptId, v.id AS versionId, v.version
|
||||||
|
FROM script_versions v JOIN scripts s ON s.id = v.script_id
|
||||||
|
WHERE s.kind = 'script' AND v.sha256 = ? ORDER BY v.version DESC`).all(sha256);
|
||||||
|
return json({ sha256, duplicates });
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { artifactVersionLabel } from '$lib/artifact-version.js';
|
import { artifactVersionLabel } from '$lib/artifact-version.js';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
|
import { parseBetaSelection } from '$lib/script-library.js';
|
||||||
import Card from '$ui/components/primitives/Card.svelte';
|
import Card from '$ui/components/primitives/Card.svelte';
|
||||||
import Badge from '$ui/components/primitives/Badge.svelte';
|
import Badge from '$ui/components/primitives/Badge.svelte';
|
||||||
import Button from '$ui/components/primitives/Button.svelte';
|
import Button from '$ui/components/primitives/Button.svelte';
|
||||||
@@ -61,6 +62,7 @@
|
|||||||
fetch('/api/scripts'),
|
fetch('/api/scripts'),
|
||||||
fetch('/api/servers')
|
fetch('/api/servers')
|
||||||
]);
|
]);
|
||||||
|
if (!betasRes.ok || !scriptsRes.ok || !serversRes.ok) throw new Error('Could not load beta setup. Refresh to try again.');
|
||||||
const bBody = (await betasRes.json()) as { betas: BetaListRow[] };
|
const bBody = (await betasRes.json()) as { betas: BetaListRow[] };
|
||||||
betas = bBody.betas;
|
betas = bBody.betas;
|
||||||
if (scriptsRes.ok) {
|
if (scriptsRes.ok) {
|
||||||
@@ -71,7 +73,6 @@
|
|||||||
const svBody = (await serversRes.json()) as { servers: ServerRow[] };
|
const svBody = (await serversRes.json()) as { servers: ServerRow[] };
|
||||||
fleetServers = svBody.servers;
|
fleetServers = svBody.servers;
|
||||||
}
|
}
|
||||||
loading = false;
|
|
||||||
}
|
}
|
||||||
async function refreshBetas(): Promise<void> {
|
async function refreshBetas(): Promise<void> {
|
||||||
const res = await fetch('/api/betas');
|
const res = await fetch('/api/betas');
|
||||||
@@ -82,7 +83,7 @@
|
|||||||
|
|
||||||
let poll: ReturnType<typeof setInterval> | null = null;
|
let poll: ReturnType<typeof setInterval> | null = null;
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
void load().then(seedImportedPlugin).catch(error => { flash = (error as Error).message; });
|
void load().then(seedSelection).catch(error => { flash = (error as Error).message; }).finally(() => { loading = false; });
|
||||||
poll = setInterval(refreshBetas, 5000);
|
poll = setInterval(refreshBetas, 5000);
|
||||||
});
|
});
|
||||||
onDestroy(() => {
|
onDestroy(() => {
|
||||||
@@ -91,7 +92,8 @@
|
|||||||
|
|
||||||
const eligibleScripts = $derived(
|
const eligibleScripts = $derived(
|
||||||
scripts.filter(
|
scripts.filter(
|
||||||
(s) => s.runningBetaId === null && s.latestVersion !== null && s.latestVersion.id !== s.current_version_id
|
(s) => s.runningBetaId === null && s.latestVersion !== null && (s.latestVersion.id !== s.current_version_id
|
||||||
|
|| picks.some(p => p.scriptId === s.id && p.versionId !== null && p.versionId !== s.current_version_id))
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -102,24 +104,26 @@
|
|||||||
return eligibleScripts.filter((s) => !takenIds.has(s.id));
|
return eligibleScripts.filter((s) => !takenIds.has(s.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function seedImportedPlugin(): Promise<void> {
|
async function seedSelection(): Promise<void> {
|
||||||
const query = new URLSearchParams(window.location.search);
|
const query = new URLSearchParams(window.location.search);
|
||||||
if (!query.has('pluginId')) return;
|
const selections = parseBetaSelection(query);
|
||||||
const pluginId = Number(query.get('pluginId'));
|
if (!selections.length) return;
|
||||||
const versionId = Number(query.get('versionId'));
|
// Validate all selections before populating; never substitute a newer upload.
|
||||||
if (!eligibleScripts.some(s => s.id === pluginId && s.kind === 'plugin')) {
|
const details = await Promise.all(selections.map(async ({ scriptId, versionId }) => {
|
||||||
flash = 'This plugin is already promoted or has a running beta. Open its detail page to review its status.';
|
const res = await fetch(`/api/scripts/${scriptId}`);
|
||||||
return;
|
if (!res.ok) throw new Error('A selected artifact is no longer available. Choose artifacts below.');
|
||||||
|
const detail = await res.json() as ScriptDetail & { runningBetaId: number | null };
|
||||||
|
if (detail.runningBetaId !== null || detail.script.current_version_id === versionId
|
||||||
|
|| !detail.versions.some(v => v.id === versionId)) {
|
||||||
|
throw new Error(`${detail.script.name}: the selected version is already promoted, unavailable, or in an active beta. Review your selection below.`);
|
||||||
}
|
}
|
||||||
picks = [{ key: nextKey++, scriptId: pluginId, versionId: null }];
|
return detail;
|
||||||
await onPickScript(0);
|
}));
|
||||||
if (!versionsForRow(picks[0]).some(v => v.id === versionId)) {
|
for (const detail of details) {
|
||||||
picks[0].versionId = null;
|
detailById[detail.script.id] = detail;
|
||||||
flash = 'The imported version is no longer available for testing. Choose a version to continue.';
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
picks[0].versionId = versionId;
|
picks = selections.map(s => ({ key: nextKey++, ...s }));
|
||||||
picks = [...picks];
|
pickedServers = new Set();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onPickScript(rowIdx: number): Promise<void> {
|
async function onPickScript(rowIdx: number): Promise<void> {
|
||||||
@@ -246,6 +250,8 @@
|
|||||||
subtitle="Test unpromoted versions on selected servers before rolling them out."
|
subtitle="Test unpromoted versions on selected servers before rolling them out."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{#if flash}<p class="flash" role="alert">{flash}</p>{/if}
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
<div class="loading">Loading…</div>
|
<div class="loading">Loading…</div>
|
||||||
{:else}
|
{:else}
|
||||||
@@ -336,7 +342,6 @@
|
|||||||
>
|
>
|
||||||
Open beta
|
Open beta
|
||||||
</Button>
|
</Button>
|
||||||
{#if flash}<span class="flash">{flash}</span>{/if}
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
+140
-360
@@ -1,387 +1,167 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import Card from '$ui/components/primitives/Card.svelte';
|
|
||||||
import Badge from '$ui/components/primitives/Badge.svelte';
|
import Badge from '$ui/components/primitives/Badge.svelte';
|
||||||
import Button from '$ui/components/primitives/Button.svelte';
|
import Button from '$ui/components/primitives/Button.svelte';
|
||||||
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
|
||||||
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
import EmptyState from '$ui/components/primitives/EmptyState.svelte';
|
||||||
import Field from '$ui/components/primitives/Field.svelte';
|
import ScriptUploadDrawer from '$ui/components/ScriptUploadDrawer.svelte';
|
||||||
import Input from '$ui/components/primitives/Input.svelte';
|
import { betaSelectionHref, canTest, mapLabel, scopeLabel, scriptStatus, statusLabels, type LibraryScript, type ScriptStatus } from '$lib/script-library.js';
|
||||||
import Select from '$ui/components/primitives/Select.svelte';
|
import { zombieMaps } from '$lib/map-aliases.js';
|
||||||
|
|
||||||
interface ScriptRow {
|
let scripts = $state<LibraryScript[]>([]);
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
target_rel_path: string;
|
|
||||||
scope_type: 'game' | 'map' | null;
|
|
||||||
scope_value: string | null;
|
|
||||||
current_version_id: number | null;
|
|
||||||
kind: 'script' | 'config' | 'plugin' | 'baseline';
|
|
||||||
latestVersion: {
|
|
||||||
id: number; version: number; sha256: string; size: number;
|
|
||||||
state: 'untested' | 'tested' | 'test-failed'; uploaded_at: string;
|
|
||||||
} | null;
|
|
||||||
versionCount: number;
|
|
||||||
servers: string[];
|
|
||||||
runningBetaId: number | null;
|
|
||||||
}
|
|
||||||
interface ScopeOptions { games: string[]; mapsByGame: Record<string, string[]>; }
|
|
||||||
|
|
||||||
let scripts = $state<ScriptRow[]>([]);
|
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let flash = $state<string | null>(null);
|
let error = $state('');
|
||||||
let uploading = $state(false);
|
let search = $state('');
|
||||||
let scopeOptions = $state<ScopeOptions>({ games: [], mapsByGame: {} });
|
let status = $state<ScriptStatus | 'all'>('all');
|
||||||
let successBanner = $state<{ name: string; scriptId: number; version: number } | null>(null);
|
let game = $state('');
|
||||||
|
let map = $state('');
|
||||||
let newName = $state('');
|
let sort = $state('attention');
|
||||||
let newScopeType = $state<'none' | 'game' | 'map'>('none');
|
let selected = $state<number[]>([]);
|
||||||
let newScopeGame = $state('');
|
let uploader: ScriptUploadDrawer;
|
||||||
let newScopeMap = $state('');
|
let dragDepth = $state(0);
|
||||||
let newFile = $state<File | null>(null);
|
const tabs: Array<{ value: ScriptStatus | 'all'; label: string }> = [
|
||||||
let newNotes = $state('');
|
{ value: 'all', label: 'All scripts' }, { value: 'untested', label: 'Needs testing' },
|
||||||
|
{ value: 'testing', label: 'Beta active' }, { value: 'failed', label: 'Test failed' },
|
||||||
const scopeValue = $derived.by(() => {
|
{ value: 'tested', label: 'Test passed' }, { value: 'promoted', label: 'Promoted' }
|
||||||
if (newScopeType === 'game') return newScopeGame;
|
|
||||||
if (newScopeType === 'map') return newScopeMap;
|
|
||||||
return '';
|
|
||||||
});
|
|
||||||
|
|
||||||
let serverCache = $state<Array<{ name: string; game: string | null; map: string | null }>>([]);
|
|
||||||
const matchingServers = $derived.by(() => {
|
|
||||||
if (newScopeType === 'none') return null;
|
|
||||||
if (!scopeValue) return null;
|
|
||||||
return serverCache
|
|
||||||
.filter((s) => (newScopeType === 'game' ? s.game : s.map) === scopeValue)
|
|
||||||
.map((s) => s.name);
|
|
||||||
});
|
|
||||||
|
|
||||||
async function load(): Promise<void> {
|
|
||||||
const [scriptsRes, scopeRes, serversRes] = await Promise.all([
|
|
||||||
fetch('/api/scripts?kind=script'),
|
|
||||||
fetch('/api/scope-options'),
|
|
||||||
fetch('/api/servers')
|
|
||||||
]);
|
|
||||||
const body = (await scriptsRes.json()) as { scripts: ScriptRow[] };
|
|
||||||
scripts = body.scripts;
|
|
||||||
scopeOptions = (await scopeRes.json()) as ScopeOptions;
|
|
||||||
const sBody = (await serversRes.json()) as { servers: Array<{ name: string; game: string | null; map: string | null }> };
|
|
||||||
serverCache = sBody.servers;
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
onMount(load);
|
|
||||||
|
|
||||||
function pickFile(e: Event): void {
|
|
||||||
const t = e.target as HTMLInputElement;
|
|
||||||
newFile = t.files?.[0] ?? null;
|
|
||||||
if (!newName && newFile) newName = newFile.name.replace(/\.[^.]+$/, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createScript(e: Event): Promise<void> {
|
|
||||||
e.preventDefault();
|
|
||||||
if (uploading || !newFile || !newName.trim()) return;
|
|
||||||
if (newScopeType !== 'none' && !scopeValue) {
|
|
||||||
flash = `Pick a ${newScopeType}`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
uploading = true; flash = null;
|
|
||||||
const form = new FormData();
|
|
||||||
form.append('name', newName.trim());
|
|
||||||
form.append('scopeType', newScopeType);
|
|
||||||
if (scopeValue) form.append('scopeValue', scopeValue);
|
|
||||||
if (newNotes.trim()) form.append('notes', newNotes.trim());
|
|
||||||
form.append('content', newFile);
|
|
||||||
try {
|
|
||||||
const res = await fetch('/api/scripts', { method: 'POST', body: form });
|
|
||||||
const body = (await res.json()) as {
|
|
||||||
ok: boolean; error?: string;
|
|
||||||
script?: { id: number; name: string };
|
|
||||||
versionId?: number;
|
|
||||||
};
|
|
||||||
if (!res.ok || !body.ok) {
|
|
||||||
flash = body.error ?? `HTTP ${res.status}`;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
successBanner = body.script
|
|
||||||
? { name: body.script.name, scriptId: body.script.id, version: 1 }
|
|
||||||
: null;
|
|
||||||
newName = ''; newScopeType = 'none';
|
|
||||||
newScopeGame = ''; newScopeMap = '';
|
|
||||||
newNotes = ''; newFile = null;
|
|
||||||
await load();
|
|
||||||
} finally {
|
|
||||||
uploading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function scopeLabel(s: ScriptRow): string {
|
|
||||||
if (!s.scope_type) return 'unscoped';
|
|
||||||
return `${s.scope_type} · ${s.scope_value ?? '?'}`;
|
|
||||||
}
|
|
||||||
function detailHref(s: ScriptRow): string {
|
|
||||||
if (s.kind === 'config' || s.kind === 'baseline') return `/configs/${s.id}`;
|
|
||||||
if (s.kind === 'plugin') return `/plugins/${s.id}`;
|
|
||||||
return `/scripts/${s.id}`;
|
|
||||||
}
|
|
||||||
function stateVariantFor(s: ScriptRow): 'online' | 'warning' | 'danger' | 'neutral' | null {
|
|
||||||
if (!s.latestVersion) return null;
|
|
||||||
if (s.current_version_id === null) return 'neutral';
|
|
||||||
if (s.latestVersion.id !== s.current_version_id) {
|
|
||||||
if (s.latestVersion.state === 'untested') return 'neutral';
|
|
||||||
if (s.latestVersion.state === 'tested') return 'online';
|
|
||||||
if (s.latestVersion.state === 'test-failed') return 'danger';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
function stateLabel(s: ScriptRow): string {
|
|
||||||
if (!s.latestVersion) return '';
|
|
||||||
if (s.current_version_id === null) return 'untested';
|
|
||||||
if (s.latestVersion.id !== s.current_version_id) {
|
|
||||||
return `v${s.latestVersion.version} ${s.latestVersion.state.replace('-', ' ')}`;
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
let gameOptions = $derived([
|
|
||||||
{ value: '', label: '— Pick a game —', disabled: true },
|
|
||||||
...scopeOptions.games.map((g) => ({ value: g, label: g }))
|
|
||||||
]);
|
|
||||||
let mapOptions = $derived([
|
|
||||||
{ value: '', label: '— Pick a map —', disabled: true },
|
|
||||||
...(scopeOptions.mapsByGame[newScopeGame] ?? []).map((m) => ({ value: m, label: m }))
|
|
||||||
]);
|
|
||||||
let scopeTypeOptions = [
|
|
||||||
{ value: 'none', label: 'Unscoped' },
|
|
||||||
{ value: 'game', label: 'Game' },
|
|
||||||
{ value: 'map', label: 'Map' }
|
|
||||||
];
|
];
|
||||||
|
const priority = { failed: 0, untested: 1, testing: 2, tested: 3, promoted: 4, empty: 5 };
|
||||||
|
const maps = $derived([...new Set(scripts.filter(s => s.scope_type === 'map' && (!game || zombieMaps(game).some(m => m.canonical === s.scope_value))).map(s => s.scope_value!))].sort());
|
||||||
|
const visible = $derived(scripts.filter(s => {
|
||||||
|
const haystack = `${s.name} ${s.target_rel_path} ${s.scope_value ?? ''} ${scopeLabel(s)}`.toLowerCase();
|
||||||
|
return search.toLowerCase().trim().split(/\s+/).every(term => haystack.includes(term))
|
||||||
|
&& (status === 'all' || scriptStatus(s) === status)
|
||||||
|
&& (!game || (s.scope_type === 'game' ? s.scope_value === game : s.scope_type === 'map' && zombieMaps(game).some(m => m.canonical === s.scope_value)))
|
||||||
|
&& (!map || s.scope_type === 'map' && s.scope_value === map);
|
||||||
|
}).sort((a, b) => {
|
||||||
|
if (sort === 'recent') return (b.latestVersion?.uploaded_at ?? '').localeCompare(a.latestVersion?.uploaded_at ?? '') || a.name.localeCompare(b.name);
|
||||||
|
if (sort === 'attention') return priority[scriptStatus(a)] - priority[scriptStatus(b)] || a.name.localeCompare(b.name);
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
}));
|
||||||
|
const selectedScripts = $derived(scripts.filter(s => selected.includes(s.id) && canTest(s)));
|
||||||
|
const selectable = $derived(visible.filter(canTest));
|
||||||
|
const allSelected = $derived(selectable.length > 0 && selectable.every(s => selected.includes(s.id)));
|
||||||
|
async function load() {
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/scripts?kind=script');
|
||||||
|
if (!res.ok) throw new Error('Could not load scripts. Try again.');
|
||||||
|
scripts = (await res.json()).scripts;
|
||||||
|
selected = selected.filter(id => scripts.some(s => s.id === id && canTest(s)));
|
||||||
|
} catch (e) { error = (e as Error).message; }
|
||||||
|
finally { loading = false; }
|
||||||
|
}
|
||||||
|
onMount(() => { void load(); });
|
||||||
|
function toggle(id: number) { selected = selected.includes(id) ? selected.filter(n => n !== id) : [...selected, id]; }
|
||||||
|
function selectVisible() {
|
||||||
|
const ids = selectable.map(s => s.id);
|
||||||
|
selected = allSelected ? selected.filter(id => !ids.includes(id)) : [...new Set([...selected, ...ids])];
|
||||||
|
}
|
||||||
|
function betaHref(rows: LibraryScript[]) { return betaSelectionHref(rows.map(s => ({ scriptId: s.id, versionId: s.latestVersion!.id }))); }
|
||||||
|
function resetFilters() { search = ''; status = 'all'; game = ''; map = ''; }
|
||||||
|
function onDrag(event: DragEvent) { if (event.dataTransfer?.types.includes('Files')) event.preventDefault(); }
|
||||||
|
function onDrop(event: DragEvent) {
|
||||||
|
if (!event.dataTransfer?.types.includes('Files')) return;
|
||||||
|
event.preventDefault(); dragDepth = 0;
|
||||||
|
void uploader.addFiles([...event.dataTransfer.files]);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:window ondragover={onDrag} ondrop={onDrop}
|
||||||
|
ondragenter={event => { if (event.dataTransfer?.types.includes('Files')) { event.preventDefault(); dragDepth++; } }}
|
||||||
|
ondragleave={() => dragDepth = Math.max(0, dragDepth - 1)} ondragend={() => dragDepth = 0} />
|
||||||
|
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<SectionHeader title="Scripts" subtitle="Upload a script, then open a beta to test it before promotion." />
|
<SectionHeader title="Scripts" eyebrow="Your script library" subtitle="Review versions, prepare a beta, and keep releases moving.">
|
||||||
|
{#snippet actions()}<Button variant="primary" onclick={() => uploader.open()}>Upload scripts</Button>{/snippet}
|
||||||
<!-- Upload form -->
|
</SectionHeader>
|
||||||
<Card padding="lg">
|
<div class="status-grid" aria-label="Filter by script status">
|
||||||
<h2 class="panel-title">Upload a script</h2>
|
{#each tabs as tab}
|
||||||
|
<button class:active={status === tab.value} aria-pressed={status === tab.value} onclick={() => status = tab.value}>
|
||||||
{#if successBanner}
|
<span>{tab.label}</span><strong>{loading ? '—' : scripts.filter(s => tab.value === 'all' || scriptStatus(s) === tab.value).length}</strong>
|
||||||
<div class="banner">
|
</button>
|
||||||
<strong>v{successBanner.version} uploaded — untested.</strong>
|
{/each}
|
||||||
<span>
|
|
||||||
Open a beta on <a href="/scripts/{successBanner.scriptId}">{successBanner.name}</a>
|
|
||||||
to test it before it can be promoted.
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
<section class="library" aria-label="Script library">
|
||||||
|
<div class="toolbar">
|
||||||
<form onsubmit={createScript} class="upload-form">
|
<label class="search"><span class="sr-only">Search scripts</span><input type="search" bind:value={search} placeholder="Search names, files, or maps…" /></label>
|
||||||
<Field label="Name">
|
<label><span class="sr-only">Filter by game</span><select bind:value={game} onchange={() => map = ''}><option value="">All games</option><option value="t5">Black Ops I · T5</option><option value="t6">Black Ops II · T6</option></select></label>
|
||||||
<Input bind:value={newName} placeholder="hud tweaks" disabled={uploading} />
|
<label><span class="sr-only">Filter by map</span><select bind:value={map}><option value="">All maps</option>{#each maps as m}<option value={m}>{mapLabel(m)}</option>{/each}</select></label>
|
||||||
</Field>
|
<label><span class="sr-only">Sort scripts</span><select bind:value={sort}><option value="attention">Needs attention first</option><option value="recent">Recently uploaded</option><option value="name">Name A–Z</option></select></label>
|
||||||
|
|
||||||
<Field label="Notes" hint="Optional. What changed in this version.">
|
|
||||||
<Input bind:value={newNotes} placeholder="what changed" disabled={uploading} />
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label="Scope" hint="Where this script deploys. Unscoped ships wherever a beta targets it.">
|
|
||||||
<div class="scope-row">
|
|
||||||
<Select bind:value={newScopeType} options={scopeTypeOptions} disabled={uploading} />
|
|
||||||
{#if newScopeType === 'game' || newScopeType === 'map'}
|
|
||||||
<Select bind:value={newScopeGame} options={gameOptions} disabled={uploading} />
|
|
||||||
{/if}
|
|
||||||
{#if newScopeType === 'map'}
|
|
||||||
<Select bind:value={newScopeMap} options={mapOptions} disabled={uploading || !newScopeGame} />
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
{#if newScopeType !== 'none' && scopeValue}
|
<div class="list-heading">
|
||||||
{@const matches = matchingServers ?? []}
|
<label class="select-all"><input type="checkbox" checked={allSelected} disabled={!selectable.length} onchange={selectVisible} /> Select visible</label>
|
||||||
{#if matches.length === 0}
|
<span>{visible.length} of {scripts.length} scripts</span>
|
||||||
<div class="preview warn">No matching servers — the promoted script will land nowhere.</div>
|
{#if search || status !== 'all' || game || map}<button class="text-button" onclick={resetFilters}>Clear filters</button>{/if}
|
||||||
{:else}
|
|
||||||
<div class="preview ok">
|
|
||||||
{matches.length} matching server{matches.length === 1 ? '' : 's'}: {matches.join(', ')}
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{#if error}<div class="error" role="alert">{error} <button class="text-button" onclick={load}>Retry</button></div>{/if}
|
||||||
{/if}
|
{#if loading}<p class="loading" role="status">Loading your library…</p>
|
||||||
</Field>
|
{:else if !scripts.length && !error}
|
||||||
|
<EmptyState title="Your next release starts here" description="Drop script files anywhere, or upload your first script to prepare a beta." />
|
||||||
<Field label="File">
|
<div class="empty-action"><Button onclick={() => uploader.open()}>Upload scripts</Button></div>
|
||||||
<Input type="file" onchange={pickFile} disabled={uploading} />
|
{:else if !visible.length && !error}
|
||||||
</Field>
|
<EmptyState title="No scripts match these filters" description="Try another name, game, map, or status." />
|
||||||
|
|
||||||
<div class="form-actions">
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
variant="primary"
|
|
||||||
disabled={uploading || !newFile || !newName}
|
|
||||||
loading={uploading}
|
|
||||||
>
|
|
||||||
Upload script
|
|
||||||
</Button>
|
|
||||||
{#if flash}<span class="flash">{flash}</span>{/if}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<!-- Script list -->
|
|
||||||
<section class="section">
|
|
||||||
<div class="section-head">
|
|
||||||
<h2>All scripts</h2>
|
|
||||||
<Badge variant="neutral" size="sm">{scripts.length}</Badge>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if loading}
|
|
||||||
<div class="loading">Loading…</div>
|
|
||||||
{:else if scripts.length === 0}
|
|
||||||
<EmptyState title="No scripts yet" description="Upload one above to get started." />
|
|
||||||
{:else}
|
{:else}
|
||||||
<div class="rows">
|
<div class="rows">
|
||||||
{#each scripts as s (s.id)}
|
{#each visible as s (s.id)}
|
||||||
<a class="row" href={detailHref(s)}>
|
{@const state = scriptStatus(s)}
|
||||||
<div class="row-top">
|
<article class="script-row" class:selected={selected.includes(s.id)}>
|
||||||
<span class="name">{s.name}</span>
|
<input type="checkbox" aria-label={`Select ${s.name} for a beta`} checked={selected.includes(s.id)} disabled={!canTest(s)} onchange={() => toggle(s.id)} />
|
||||||
<Badge
|
<div class="script-info">
|
||||||
variant={s.scope_type === 'game' ? 'info' :
|
<div class="name-row"><a class="name" href="/scripts/{s.id}">{s.name}</a><Badge variant={state === 'failed' ? 'danger' : state === 'testing' ? 'warning' : state === 'promoted' || state === 'tested' ? 'online' : 'neutral'} size="sm">{statusLabels[state]}</Badge></div>
|
||||||
s.scope_type === 'map' ? 'warning' : 'neutral'}
|
<div class="path">{s.target_rel_path}</div>
|
||||||
size="sm"
|
<div class="meta"><span title={s.scope_value ?? 'Choose targets explicitly when opening a beta'}>{scopeLabel(s)}</span><span>{s.scope_type ? `${s.servers.length} scope matches` : 'Explicit beta targets'}</span><span>{s.versionCount} version{s.versionCount === 1 ? '' : 's'}</span></div>
|
||||||
>
|
|
||||||
{scopeLabel(s)}
|
|
||||||
</Badge>
|
|
||||||
{#if stateVariantFor(s)}
|
|
||||||
{@const v = stateVariantFor(s)!}
|
|
||||||
<Badge variant={v} size="sm">{stateLabel(s)}</Badge>
|
|
||||||
{/if}
|
|
||||||
{#if s.runningBetaId}
|
|
||||||
<Badge variant="warning" size="sm">β{s.runningBetaId} running</Badge>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="row-meta">
|
<div class="versions">
|
||||||
<span class="path">{s.target_rel_path}</span>
|
<span>{s.currentVersion ? `Promoted v${s.currentVersion.version}` : 'Never promoted'}</span>
|
||||||
<span>v{s.latestVersion?.version ?? '—'} · {s.versionCount} version{s.versionCount === 1 ? '' : 's'}</span>
|
{#if s.latestVersion && s.latestVersion.id !== s.current_version_id}<strong>Latest v{s.latestVersion.version} · {s.latestVersion.state === 'untested' ? 'awaiting test' : s.latestVersion.state.replaceAll('-', ' ')}</strong>{/if}
|
||||||
{#if s.servers.length}
|
|
||||||
<span>{s.servers.length} matching server{s.servers.length === 1 ? '' : 's'}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
</div>
|
||||||
</a>
|
<div class="row-action">
|
||||||
|
{#if s.runningBetaId !== null}<Button size="sm" href="/betas/{s.runningBetaId}">View beta →</Button>
|
||||||
|
{:else if state === 'failed'}<Button size="sm" href="/scripts/{s.id}">Review version</Button>
|
||||||
|
{:else if canTest(s)}<Button size="sm" href={betaHref([s])}>Test version →</Button>
|
||||||
|
{:else}<Button size="sm" onclick={() => uploader.open(s.id)}>Upload version</Button>{/if}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
<p class="hint">Drop files anywhere to add scripts or recognize new versions. Scope matches describe eligible servers, not installed versions.</p>
|
||||||
</section>
|
</section>
|
||||||
|
{#if selectedScripts.length}
|
||||||
|
<div class="selection-bar" role="region" aria-label="Selected scripts">
|
||||||
|
<strong>{selectedScripts.length} selected</strong><span>Latest versions will be carried into beta setup.</span>
|
||||||
|
<Button size="sm" variant="ghost" onclick={() => selected = []}>Clear</Button>
|
||||||
|
<Button variant="primary" href={betaHref(selectedScripts)}>Test selected →</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{#if dragDepth > 0}<div class="drop-overlay"><strong>Drop your scripts here</strong><span>We’ll recognize existing scripts and check for duplicate versions.</span></div>{/if}
|
||||||
|
<ScriptUploadDrawer bind:this={uploader} {scripts} onuploaded={load} />
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.page { display: flex; flex-direction: column; gap: var(--space-6); }
|
.page { display: flex; flex-direction: column; gap: var(--space-5); padding-bottom: 90px; }
|
||||||
.loading { text-align: center; padding: var(--space-12); color: var(--text-tertiary); }
|
.status-grid { display: grid; grid-template-columns: repeat(6, 1fr); gap: var(--space-2); }
|
||||||
|
.status-grid button { text-align: left; display: flex; flex-direction: column; gap: 12px; padding: 18px; background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); color: var(--text-secondary); cursor: pointer; font: inherit; }
|
||||||
.panel-title {
|
.status-grid button.active { border-color: var(--accent-primary); background: color-mix(in srgb, var(--accent-primary) 8%, var(--surface)); }
|
||||||
margin: 0 0 var(--space-4);
|
.status-grid span { font-size: var(--text-xs); } .status-grid strong { color: var(--text-primary); font-size: var(--text-2xl); }
|
||||||
font-family: var(--font-body);
|
.toolbar { display: flex; flex-wrap: wrap; gap: 10px; } .search { flex: 1 1 260px; } .toolbar label { min-width: 0; }
|
||||||
font-size: var(--text-md);
|
input[type='search'], select { width: 100%; min-height: 42px; padding: 10px 12px; border: 1px solid var(--border-subtle); border-radius: var(--radius-md); background: var(--surface-sunken); color: var(--text-primary); font: inherit; font-size: var(--text-sm); }
|
||||||
font-weight: var(--weight-semibold);
|
input[type='checkbox'] { accent-color: var(--accent-primary); width: 16px; height: 16px; flex-shrink: 0; }
|
||||||
color: var(--text-primary);
|
button:focus-visible, input:focus-visible, select:focus-visible, a:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 3px; }
|
||||||
}
|
.list-heading { display: flex; flex-wrap: wrap; gap: 18px; align-items: center; padding: 20px 4px 12px; font-size: var(--text-xs); color: var(--text-secondary); }
|
||||||
.banner {
|
.select-all { display: flex; align-items: center; gap: 10px; } .text-button { background: none; border: 0; color: var(--accent-primary); cursor: pointer; font: inherit; }
|
||||||
display: flex;
|
.rows { display: flex; flex-direction: column; gap: 8px; } .script-row { display: grid; grid-template-columns: auto minmax(0, 1fr) minmax(160px, auto) auto; gap: 20px; align-items: center; padding: 20px; background: var(--surface); border: 1px solid var(--border-subtle); border-radius: var(--radius-md); }
|
||||||
flex-direction: column;
|
.script-row.selected { border-color: var(--accent-primary); } .name-row { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }
|
||||||
gap: 4px;
|
.name { font-weight: 600; color: var(--text-primary); text-decoration: none; overflow-wrap: anywhere; } .name:hover { color: var(--accent-primary); }
|
||||||
padding: var(--space-3) var(--space-4);
|
.path { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--text-tertiary); margin-top: 6px; overflow-wrap: anywhere; }
|
||||||
margin-bottom: var(--space-4);
|
.meta { display: flex; gap: 14px; flex-wrap: wrap; font-size: var(--text-xs); color: var(--text-secondary); margin-top: 12px; }
|
||||||
background: rgba(124, 212, 255, 0.06);
|
.versions { display: flex; flex-direction: column; gap: 7px; font-size: var(--text-xs); color: var(--text-secondary); } .versions strong { font-weight: 500; color: var(--text-primary); }
|
||||||
border: 1px solid rgba(124, 212, 255, 0.22);
|
.hint { font-size: var(--text-xs); color: var(--text-tertiary); margin-top: 20px; line-height: 1.6; }
|
||||||
border-radius: var(--radius-md);
|
.selection-bar { position: sticky; bottom: 20px; display: flex; align-items: center; flex-wrap: wrap; gap: 16px; padding: 16px 20px; border: 1px solid var(--accent-primary); border-radius: var(--radius-md); background: var(--surface-raised); box-shadow: 0 8px 32px #0005; }
|
||||||
font-size: var(--text-sm);
|
.selection-bar span { flex: 1; color: var(--text-secondary); font-size: var(--text-sm); }
|
||||||
color: var(--text-primary);
|
.drop-overlay { position: fixed; inset: 16px; z-index: 100; pointer-events: none; display: flex; flex-direction: column; justify-content: center; align-items: center; gap: 16px; padding: 24px; text-align: center; background: color-mix(in srgb, var(--surface) 95%, transparent); border: 2px dashed var(--accent-primary); border-radius: var(--radius-md); }
|
||||||
}
|
.drop-overlay strong { font-size: var(--text-2xl); } .loading, .empty-action { text-align: center; padding: 24px; } .error { padding: 16px; color: var(--state-danger); }
|
||||||
.banner a { color: var(--state-online); text-decoration: underline; }
|
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
|
||||||
|
@media (max-width: 1100px) { .status-grid { grid-template-columns: repeat(3, 1fr); } .script-row { grid-template-columns: auto minmax(0, 1fr) auto; } .versions { grid-column: 2; } .row-action { grid-column: 3; grid-row: 1 / 3; } }
|
||||||
.upload-form {
|
@media (max-width: 640px) { .status-grid { grid-template-columns: repeat(2, 1fr); } .status-grid button { padding: 12px; gap: 6px; } .status-grid strong { font-size: 24px; } .script-row { gap: 12px; padding: 16px; grid-template-columns: auto minmax(0, 1fr); } .row-action { grid-column: 2; grid-row: auto; } .toolbar label { flex: 1 1 140px; } .toolbar .search { flex-basis: 100%; } .selection-bar span { display: none; } }
|
||||||
display: grid;
|
|
||||||
gap: var(--space-4);
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
}
|
|
||||||
.scope-row {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--space-2);
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.scope-row :global(.wrap) { flex: 1 1 140px; min-width: 140px; }
|
|
||||||
.preview {
|
|
||||||
margin-top: var(--space-2);
|
|
||||||
padding: var(--space-2) var(--space-3);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
}
|
|
||||||
.preview.ok { background: var(--surface-sunken); color: var(--text-secondary); }
|
|
||||||
.preview.warn { background: rgba(255, 176, 32, 0.06); color: var(--state-warning); border: 1px solid rgba(255, 176, 32, 0.28); }
|
|
||||||
|
|
||||||
.form-actions {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
grid-column: 1 / -1;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-4);
|
|
||||||
}
|
|
||||||
.flash {
|
|
||||||
color: var(--state-danger);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.section { display: flex; flex-direction: column; gap: var(--space-3); }
|
|
||||||
.section-head {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: var(--space-3);
|
|
||||||
}
|
|
||||||
.section-head h2 {
|
|
||||||
margin: 0;
|
|
||||||
font-family: var(--font-body);
|
|
||||||
font-size: var(--text-md);
|
|
||||||
font-weight: var(--weight-semibold);
|
|
||||||
color: var(--text-primary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.rows { display: flex; flex-direction: column; gap: var(--space-2); }
|
|
||||||
.row {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-2);
|
|
||||||
padding: var(--space-4) var(--space-5);
|
|
||||||
background: color-mix(in oklab, var(--surface) 88%, transparent);
|
|
||||||
border: 1px solid var(--border-subtle);
|
|
||||||
border-radius: var(--radius-md);
|
|
||||||
color: var(--text-primary);
|
|
||||||
text-decoration: none;
|
|
||||||
transition:
|
|
||||||
background-color var(--dur-fast) var(--ease-standard),
|
|
||||||
border-color var(--dur-fast) var(--ease-standard);
|
|
||||||
}
|
|
||||||
.row:hover { background: var(--surface-raised); border-color: var(--border-strong); }
|
|
||||||
.row:focus-visible { outline: none; box-shadow: var(--focus-ring); }
|
|
||||||
.row-top {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--space-2);
|
|
||||||
align-items: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.name {
|
|
||||||
font-size: var(--text-md);
|
|
||||||
font-weight: var(--weight-semibold);
|
|
||||||
color: var(--text-primary);
|
|
||||||
letter-spacing: var(--tracking-tight);
|
|
||||||
}
|
|
||||||
.row-meta {
|
|
||||||
display: flex;
|
|
||||||
gap: var(--space-5);
|
|
||||||
color: var(--text-tertiary);
|
|
||||||
font-family: var(--font-mono);
|
|
||||||
font-size: var(--text-xs);
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.path { color: var(--text-secondary); }
|
|
||||||
|
|
||||||
@media (max-width: 720px) {
|
|
||||||
.upload-form { grid-template-columns: 1fr; }
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { betaSelectionHref, canTest, mapLabel, parseBetaSelection, scriptStatus, type LibraryScript } from '../../src/lib/script-library.js';
|
||||||
|
|
||||||
|
const script: LibraryScript = {
|
||||||
|
id: 1, name: 'HUD', target_rel_path: 'hud.gsc', scope_type: null, scope_value: null,
|
||||||
|
current_version_id: null, currentVersion: null, versionCount: 1, servers: [], runningBetaId: null,
|
||||||
|
latestVersion: { id: 10, version: 1, sha256: 'abc', size: 12, state: 'untested', uploaded_at: '2026-09-18' }
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('script library state', () => {
|
||||||
|
it.each([['untested', 'untested'], ['tested', 'tested'], ['test-failed', 'failed']] as const)(
|
||||||
|
'preserves %s for a never-promoted version', (state, expected) => {
|
||||||
|
expect(scriptStatus({ ...script, latestVersion: { ...script.latestVersion!, state } })).toBe(expected);
|
||||||
|
});
|
||||||
|
it('distinguishes an active beta, a promoted release, and a newer upload', () => {
|
||||||
|
expect(scriptStatus({ ...script, runningBetaId: 5 })).toBe('testing');
|
||||||
|
expect(canTest({ ...script, runningBetaId: 5 })).toBe(false);
|
||||||
|
expect(scriptStatus({ ...script, current_version_id: 10 })).toBe('promoted');
|
||||||
|
expect(canTest({ ...script, current_version_id: 10 })).toBe(false);
|
||||||
|
expect(scriptStatus({ ...script, current_version_id: 9 })).toBe('untested');
|
||||||
|
expect(canTest({ ...script, current_version_id: 9 })).toBe(true);
|
||||||
|
expect(scriptStatus({ ...script, latestVersion: null })).toBe('empty');
|
||||||
|
});
|
||||||
|
it('does not describe shared T6 engine maps as a single variant', () => {
|
||||||
|
expect(mapLabel('zombie_moon')).toBe('Moon');
|
||||||
|
expect(mapLabel('zm_transit')).toMatch(/variants/);
|
||||||
|
expect(mapLabel('custom_map')).toBe('custom_map');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('beta selection links', () => {
|
||||||
|
it('round-trips exact versions in a multi-script selection', () => {
|
||||||
|
const picks = [{ scriptId: 4, versionId: 20 }, { scriptId: 7, versionId: 25 }];
|
||||||
|
expect(parseBetaSelection(new URL(betaSelectionHref(picks), 'http://localhost').searchParams)).toEqual(picks);
|
||||||
|
});
|
||||||
|
it('preserves existing plugin import links', () => {
|
||||||
|
expect(parseBetaSelection(new URLSearchParams('pluginId=8&versionId=32'))).toEqual([{ scriptId: 8, versionId: 32 }]);
|
||||||
|
});
|
||||||
|
it.each(['artifact=0:1', 'artifact=1:NaN', 'artifact=1:2junk', 'artifact=1:2&artifact=1:3', 'artifact=9007199254740992:1', 'pluginId=8'])(
|
||||||
|
'rejects malformed or ambiguous selections: %s', query => {
|
||||||
|
expect(() => parseBetaSelection(new URLSearchParams(query))).toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||||
|
|
||||||
|
let fixture: Fixture;
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules(); fixture = await newFixture();
|
||||||
|
delete process.env.KOMODO_ADDRESS; delete process.env.RCON_PASSWORD;
|
||||||
|
});
|
||||||
|
afterEach(async () => { await fixture.cleanup(); });
|
||||||
|
function request(content: string, reuse = false) {
|
||||||
|
const form = new FormData(); form.append('content', new File([content], 'hud.gsc'));
|
||||||
|
if (reuse) form.append('reuseExisting', 'true');
|
||||||
|
return new Request('http://localhost/api/scripts/inspect', { method: 'POST', body: form });
|
||||||
|
}
|
||||||
|
async function seed() {
|
||||||
|
const mod = await import('../../src/lib/server/fleet/scripts.js');
|
||||||
|
return { ...mod, ...mod.createScript({ name: 'HUD', targetRelPath: 'hud.gsc', scopeType: 'game', scopeValue: 't5', content: Buffer.from('main(){}') }) };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('script upload review', () => {
|
||||||
|
it('inspects content against all versions without creating records or including plugins', async () => {
|
||||||
|
const { script, version, uploadNewVersion, createScript, listVersions } = await seed();
|
||||||
|
uploadNewVersion(script.id, Buffer.from('changed(){}'));
|
||||||
|
createScript({ name: 'Plugin', targetRelPath: 'plugin.dll', kind: 'plugin', content: Buffer.from('main(){}') });
|
||||||
|
const { POST } = await import('../../src/routes/api/scripts/inspect/+server.js');
|
||||||
|
const res = await POST({ request: request('main(){}') });
|
||||||
|
expect(await res.json()).toEqual({ sha256: version.sha256, duplicates: [{ scriptId: script.id, versionId: version.id, version: 1 }] });
|
||||||
|
expect(listVersions(script.id)).toHaveLength(2);
|
||||||
|
expect((await POST({ request: request('') })).status).toBe(400);
|
||||||
|
});
|
||||||
|
it('reuses identical historical content and creates changed versions while preserving scope', async () => {
|
||||||
|
const { script, version, uploadNewVersion, listVersions, getScript } = await seed();
|
||||||
|
uploadNewVersion(script.id, Buffer.from('changed(){}'));
|
||||||
|
const { POST } = await import('../../src/routes/api/scripts/[id]/versions/+server.js');
|
||||||
|
const invoke = (content: string) => POST({ params: { id: String(script.id) }, request: request(content, true) });
|
||||||
|
const res = await invoke('main(){}');
|
||||||
|
expect(await res.json()).toMatchObject({ ok: true, reused: true, version: { id: version.id, version: 1 } });
|
||||||
|
expect(listVersions(script.id)).toHaveLength(2);
|
||||||
|
const added = await invoke('third(){}');
|
||||||
|
expect(await added.json()).toMatchObject({ ok: true, version: { version: 3 } });
|
||||||
|
expect(listVersions(script.id)).toHaveLength(3);
|
||||||
|
expect(getScript(script.id)).toMatchObject({ scope_type: 'game', scope_value: 't5' });
|
||||||
|
expect((await invoke('')).status).toBe(400);
|
||||||
|
expect(listVersions(script.id)).toHaveLength(3);
|
||||||
|
});
|
||||||
|
it('rejects new versions when a beta becomes active after the upload preview', async () => {
|
||||||
|
const { script, version, listVersions } = await seed();
|
||||||
|
const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js');
|
||||||
|
await seedServerRegistry();
|
||||||
|
const { openBeta } = await import('../../src/lib/server/fleet/beta.js');
|
||||||
|
await openBeta({ scripts: [{ scriptId: script.id, scriptVersionId: version.id }], servers: ['t5_zombie_theater_1'], waitForHealth: false, waitForMatch: false });
|
||||||
|
const { POST } = await import('../../src/routes/api/scripts/[id]/versions/+server.js');
|
||||||
|
const res = await POST({ params: { id: String(script.id) }, request: request('changed(){}', true) });
|
||||||
|
expect(res.status).toBe(409);
|
||||||
|
expect(listVersions(script.id)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
it('lists promoted and latest version metadata separately without file contents', async () => {
|
||||||
|
const { script, version, uploadNewVersion } = await seed();
|
||||||
|
const latest = uploadNewVersion(script.id, Buffer.from('changed(){}'));
|
||||||
|
const { db } = await import('../../src/lib/server/db/index.js');
|
||||||
|
db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(version.id, script.id);
|
||||||
|
const { GET } = await import('../../src/routes/api/scripts/+server.js');
|
||||||
|
const body = await (await GET({ url: new URL('http://localhost/api/scripts?kind=script') })).json();
|
||||||
|
expect(body.scripts[0]).toMatchObject({ currentVersion: { id: version.id, version: 1 }, latestVersion: { id: latest.id, version: 2 } });
|
||||||
|
expect(body.scripts[0].currentVersion).not.toHaveProperty('content');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user