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); +});