Add all eligible files to beta setup
CI / checks (push) Successful in 5m59s

This commit is contained in:
2026-10-14 19:47:31 -04:00
parent 1fc329b123
commit 68616327ec
3 changed files with 202 additions and 5 deletions
+4
View File
@@ -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
+35 -5
View File
@@ -84,6 +84,7 @@
allowLeave = false;
let hasLinkedSetup = $state(false);
let loadingVersions = $state<Set<number>>(new Set());
let addingAllFiles = $state(false);
const rowRequests = new Map<number, number>();
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 @@
</p>{/if}{/if}
</section>{/each}
</div>
<Button
onclick={() =>
(picks = [...picks, { key: nextKey++, scriptId: null, versionId: null }])}
disabled={picks.length >= eligibleFiles.length}>Add file</Button
>
<div class="actions">
<Button
onclick={() =>
(picks = [
...picks,
{ key: nextKey++, scriptId: null, versionId: null }
])}
disabled={picks.length >= eligibleFiles.length}>Add file</Button
>
<Button
onclick={addAllFiles}
disabled={!remainingFiles.length || loadingVersions.size > 0}
loading={addingAllFiles}>Add all eligible files</Button
>
</div>
<p class="muted">
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.
+163
View File
@@ -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<typeof currentPage.subscribe>[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<typeof mount> | undefined;
beforeEach(() => {
const storage = new Map<string, string>();
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);
});