From 68616327ec8e9aacc399f31176c3dbe37e7124e6 Mon Sep 17 00:00:00 2001
From: Josh Wright
Date: Wed, 14 Oct 2026 19:47:31 -0400
Subject: [PATCH] Add all eligible files to beta setup
---
docs/guides/beta-test-plans.md | 4 +
src/routes/betas/new/+page.svelte | 40 +++++++-
tests/client/beta-setup.test.ts | 163 ++++++++++++++++++++++++++++++
3 files changed, 202 insertions(+), 5 deletions(-)
create mode 100644 tests/client/beta-setup.test.ts
diff --git a/docs/guides/beta-test-plans.md b/docs/guides/beta-test-plans.md
index f978931..d567656 100644
--- a/docs/guides/beta-test-plans.md
+++ b/docs/guides/beta-test-plans.md
@@ -12,6 +12,10 @@ file applies to all participants. Every file needs at least one matching test
participant. For example, a T5 script and a T6 script can share one beta without
either script being installed on the other game's servers.
+Choose **Add all eligible files** to add the latest unpromoted version of every
+remaining eligible file at once. Files and exact versions you already selected
+stay unchanged.
+
Setup defaults to automatic selection. Enter one **Instances per game** number,
which defaults to 1 and applies to every game represented by the selected files
and their matching servers. Every automatically selected instance must pass
diff --git a/src/routes/betas/new/+page.svelte b/src/routes/betas/new/+page.svelte
index afade2c..22524ff 100644
--- a/src/routes/betas/new/+page.svelte
+++ b/src/routes/betas/new/+page.svelte
@@ -84,6 +84,7 @@
allowLeave = false;
let hasLinkedSetup = $state(false);
let loadingVersions = $state>(new Set());
+ let addingAllFiles = $state(false);
const rowRequests = new Map();
let versionRequest = 0;
let betaLabel = $state('');
@@ -103,6 +104,9 @@
picks.some((pick) => pick.scriptId === file.id && versionsFor(pick).length > 0))
)
);
+ const remainingFiles = $derived(
+ eligibleFiles.filter((file) => !picks.some((pick) => pick.scriptId === file.id))
+ );
const filesReady = $derived(
picks.length > 0 &&
picks.every(
@@ -526,6 +530,22 @@
loadingVersions = new Set([...loadingVersions].filter((key) => key !== pick.key));
}
}
+ async function addAllFiles() {
+ if (addingAllFiles || loadingVersions.size || !remainingFiles.length) return;
+ const selected = picks.filter((pick) => pick.scriptId !== null);
+ const additions = remainingFiles.map((file) => ({
+ key: nextKey++,
+ scriptId: file.id,
+ versionId: null
+ }));
+ picks = [...selected, ...additions];
+ addingAllFiles = true;
+ try {
+ await Promise.all(additions.map((_, index) => onPickFile(selected.length + index)));
+ } finally {
+ addingAllFiles = false;
+ }
+ }
function toggleServer(name: string) {
const next = new Set(pickedServers);
if (next.has(name)) next.delete(name);
@@ -749,11 +769,21 @@
{/if}{/if}
{/each}
-
+
+
+
+
Combine files for different games in one beta. Each file installs only on
servers matching its scope, and every file needs at least one test participant.
diff --git a/tests/client/beta-setup.test.ts b/tests/client/beta-setup.test.ts
new file mode 100644
index 0000000..780001e
--- /dev/null
+++ b/tests/client/beta-setup.test.ts
@@ -0,0 +1,163 @@
+import { afterEach, beforeEach, expect, it, vi } from 'vitest';
+import { flushSync, mount, unmount } from 'svelte';
+import { writable } from 'svelte/store';
+import BetaSetupPage from '../../src/routes/betas/new/+page.svelte';
+
+const currentPage = writable({
+ url: new URL('http://localhost/betas/new'),
+ data: { user: { role: 'operator' } }
+});
+vi.mock('$app/stores', () => ({
+ page: {
+ subscribe: (run: Parameters[0]) => currentPage.subscribe(run)
+ }
+}));
+vi.mock('$app/navigation', () => ({ goto: vi.fn(), beforeNavigate: vi.fn() }));
+vi.mock('../../src/lib/ui/stores/fleet.js', () => ({
+ fleetServers: writable([]),
+ fleetError: writable(''),
+ refreshFleet: vi.fn().mockResolvedValue(undefined)
+}));
+
+let component: ReturnType | undefined;
+beforeEach(() => {
+ const storage = new Map();
+ vi.stubGlobal('localStorage', {
+ getItem: (key: string) => storage.get(key) ?? null,
+ setItem: (key: string, value: string) => storage.set(key, value),
+ removeItem: (key: string) => storage.delete(key)
+ });
+ window.history.replaceState({}, '', '/betas/new');
+});
+afterEach(async () => {
+ if (component) await unmount(component);
+ component = undefined;
+ document.body.replaceChildren();
+ vi.unstubAllGlobals();
+});
+
+function file(
+ id: number,
+ options: {
+ currentVersionId?: number;
+ latestVersionId?: number | null;
+ runningBetaId?: number;
+ } = {}
+) {
+ const latestVersionId =
+ options.latestVersionId === undefined ? id * 10 + 2 : options.latestVersionId;
+ return {
+ id,
+ name: `File ${id}`,
+ kind: 'script',
+ target_rel_path: `file-${id}.gsc`,
+ scope_type: null,
+ scope_value: null,
+ current_version_id: options.currentVersionId ?? id * 10,
+ latestVersion: latestVersionId === null ? null : { id: latestVersionId },
+ runningBetaId: options.runningBetaId ?? null
+ };
+}
+const files = [
+ file(1),
+ file(2),
+ file(3, { currentVersionId: 32 }),
+ file(4, { runningBetaId: 7 }),
+ file(5, { latestVersionId: null })
+];
+const button = (label: string) =>
+ [...document.querySelectorAll('button')].find((node) => node.textContent?.trim() === label)!;
+const selections = () =>
+ [...document.querySelectorAll('.pick-row')].map((row) =>
+ [...row.querySelectorAll('select')].map((select) => select.value)
+ );
+
+async function show({ query = '', failingFile = 0 } = {}) {
+ window.history.replaceState({}, '', `/betas/new${query}`);
+ currentPage.set({
+ url: new URL(window.location.href),
+ data: { user: { role: 'operator' } }
+ });
+ const fetch = vi.fn(async (url: string) => {
+ if (url === '/api/scripts') return Response.json({ scripts: files });
+ const script = files.find((entry) => url === `/api/scripts/${entry.id}`);
+ if (!script) throw new Error(`Unexpected request: ${url}`);
+ if (script.id === failingFile) return new Response('', { status: 503 });
+ return Response.json({
+ script,
+ versions: [2, 1, 0].map((version) => ({
+ id: script.id * 10 + version,
+ version: version + 1,
+ release_version: `1.0.${version}`,
+ sha256: `hash-${script.id}-${version}`,
+ state: 'untested'
+ })),
+ matchedServers: []
+ });
+ });
+ vi.stubGlobal('fetch', fetch);
+ component = mount(BetaSetupPage, { target: document.body });
+ flushSync();
+ await vi.waitFor(() => {
+ flushSync();
+ expect(button('Add all eligible files')).toBeDefined();
+ });
+ return fetch;
+}
+
+it('adds every eligible file with its latest unpromoted version and replaces empty rows', async () => {
+ const fetch = await show();
+ button('Add file').click();
+ flushSync();
+ expect(document.querySelectorAll('.pick-row')).toHaveLength(2);
+ button('Add all eligible files').click();
+ await vi.waitFor(() => {
+ flushSync();
+ expect(selections()).toEqual([
+ ['1', '12'],
+ ['2', '22']
+ ]);
+ });
+ expect(button('Continue').disabled).toBe(false);
+ expect(button('Add all eligible files').disabled).toBe(true);
+ button('Add all eligible files').click();
+ flushSync();
+ expect(document.querySelectorAll('.pick-row')).toHaveLength(2);
+ expect(fetch.mock.calls.map(([url]) => url)).toEqual([
+ '/api/scripts',
+ '/api/scripts/1',
+ '/api/scripts/2'
+ ]);
+});
+
+it('preserves exact linked versions, including an older upload of a promoted file', async () => {
+ await show({ query: '?artifact=1:11&artifact=3:31' });
+ button('Add file').click();
+ flushSync();
+ button('Add all eligible files').click();
+ await vi.waitFor(() => {
+ flushSync();
+ expect(selections()).toEqual([
+ ['1', '11'],
+ ['3', '31'],
+ ['2', '22']
+ ]);
+ });
+ expect(button('Continue').disabled).toBe(false);
+});
+
+it('retains successful selections and blocks continuing when one file cannot load versions', async () => {
+ await show({ failingFile: 2 });
+ button('Add all eligible files').click();
+ await vi.waitFor(() => {
+ flushSync();
+ expect(document.querySelector('[role="alert"]')?.textContent).toContain(
+ 'Could not load versions'
+ );
+ });
+ expect(selections()[0]).toEqual(['1', '12']);
+ expect(selections()[1][0]).toBe('2');
+ const failedVersion = document.querySelectorAll('.pick-row')[1].querySelectorAll('select')[1];
+ expect(failedVersion.selectedOptions[0].textContent).toBe('Choose a version');
+ expect(button('Continue').disabled).toBe(true);
+});