diff --git a/docs/guides/collections.md b/docs/guides/collections.md index 08b8724..a048782 100644 --- a/docs/guides/collections.md +++ b/docs/guides/collections.md @@ -139,22 +139,28 @@ Every server links to its Provisioning operation timeline and recovery options, and each child operation links back to its collection launch. Individual operations remain visible in Provisioning activity and history. -## Create and review a single-server launch +## Create a single server -1. Choose **Single server** on Provisioning (or the deploy action on the fleet). -2. Choose the current promoted setup and select a game/map, or select a collection. -3. Choose **Preview launch**. Review the image reference, configuration sources, - managed files and versions, selected map, lobby, port, and prerequisite checks. -4. The launch automatically uses an available key from the game pool. If none is - available, manage the pool under **Administration → Keys**, then refresh the - preview. -5. Choose **Launch server** and follow the operation to verified health. +1. Open **Create server** from the fleet or **Single server** from Provisioning. +2. Choose a game and map. The dialog automatically checks the current promoted + setup, latest game files, available lobby, and game key pool as selections change. +3. Review the readiness summary and resolve any displayed blockers. When no game + key is available, open **Administration → Keys** and add one or wait for a key + to be released. Returning to the launch dialog rechecks availability; **Retry checks** + also runs the checks again. +4. If needed, expand **Advanced settings** for runtime and network overrides. + Expand **Setup details** to inspect the image, configuration, managed files, + game-file upload time, and internal server ID. +5. Choose **Launch server** in the persistent footer and follow the operation to + verified health. **Cancel** closes the dialog without creating a server. -A preview lasts 30 minutes. It reserves neither capacity nor a key. If -another launch takes the lobby, submission stops before reserving a key; -refresh the preview and review the newly assigned ID. Pool availability is checked again at -submission, and keys are reserved atomically so concurrent launches cannot share -a credential. +The single-server dialog uses the current promoted setup. Create and launch +collections separately from **Fleet → Collections**. + +Automatic checks reserve neither capacity nor a key. The reviewed preview lasts +30 minutes. If another launch takes the lobby or key, submission stops; retry the +checks before launching. Pool availability is checked again at submission, and +keys are reserved atomically so concurrent launches cannot share a credential. Host selection, game-file versions, runtime configuration and lobby availability are checked before queueing. Provisioning assigns host capacity and physical @@ -165,9 +171,11 @@ verify the engine map, not the variant's start location. ## Save and reuse a collection -Expand **Make this a reusable collection** in a launch preview and give it a name -and optional description. You can save the map even when host configuration or -available capacity blocks launching. Names are unique without regard to case. +Choose **Create collection** under **Fleet → Collections**, give it a name and +optional description, and add maps with their desired lobby counts. A collection +can contain a single map with a target of one lobby. You can save it even when +host configuration or available capacity blocks launching. Names are unique +without regard to case. Each map in a collection retains the game, exact map variant, and desired lobby count. Image references, managed files, and composed configuration are resolved @@ -178,11 +186,12 @@ Use **Conform fleet** on a collection card to review missing servers. Remove a collection from the library when it is no longer needed; its existing servers, reviewed previews, and deployment history remain intact. -On a creation detail page, **Launch again** selects the original game and map -with the next available lobby and port, using the latest promoted setup. The -preview also lets you save that map as a collection with a target of one lobby. -The recorded files remain visible as deployment history. Older deployments -without a recorded recipe retain their history but cannot offer this action. +On a creation detail page, **Launch another server** opens the single-server +dialog with the original game and map selected. You can change either selection +before launching. Checks use the current promoted setup and latest game files, +and provisioning assigns a fresh lobby and port. The recorded files remain +visible as deployment history. Older deployments without a recorded recipe +retain their history but cannot offer this action. ## Promotions and deployment history @@ -190,7 +199,7 @@ A preview shows the currently promoted managed files, configuration, and image reference for the selected map. It does not pin those versions. New launches resolve the promoted setup again when provisioning begins, so promotions made after a preview or while a job is queued apply to that server. -This applies to collections, single-server launches, and **Launch again**. +This applies to collections, single-server launches, and **Launch another server**. Previews and queued jobs store file metadata without copying script or plugin contents for every lobby. Provisioning captures the bytes when each job starts. diff --git a/docs/guides/getting-started.md b/docs/guides/getting-started.md index 81c04e4..e12f4fd 100644 --- a/docs/guides/getting-started.md +++ b/docs/guides/getting-started.md @@ -68,9 +68,10 @@ base game files; each instance has its own writable data. See ## Launch your first server 1. Save Plutonium server keys in the T5 or T6 pool under **Administration → Keys**. -2. Create a server, or build a reusable map lineup in **Fleet → Collections**. -3. Review the launch and apply it. Provisioning queues the work, then assigns a - host and port when capacity is available. +2. Open **Create server** and choose a game and map. Readiness checks run + automatically. For a reusable lineup, use **Fleet → Collections** instead. +3. Resolve any launch blockers and choose **Launch server**. Provisioning queues + the work, then assigns a host and port when capacity is available. 4. Follow the operation until readiness checks pass, then verify that players can find and join the server. diff --git a/docs/guides/native-hosts.md b/docs/guides/native-hosts.md index ea2b720..8114007 100644 --- a/docs/guides/native-hosts.md +++ b/docs/guides/native-hosts.md @@ -26,12 +26,14 @@ the agent owns local execution and its durable operation journal. progress and errors. Wait for verification before launching. Hosts report connection and readiness separately; deployment also checks the particular game files, image, capacity and ports required for the launch. -6. Launch a server or collection. The latest game files and default runtime - are selected automatically. An advanced runtime override is - available for images an admin has already prepared on the destination host. - If the latest files change after previewing, refresh the preview - before launching. - Apply saves the requested lobby IDs. Provisioning selects a host and atomically +6. Open **Create server**, choose a game and map, and review the automatic + readiness checks. Launch collections separately from **Fleet → Collections**. + The latest game files and default runtime are selected automatically. + **Advanced settings** in the single-server dialog includes a runtime override + for images an admin has already prepared on the destination host, along with + optional network overrides. If the latest files change after the checks run, + let the dialog recheck the setup before launching; **Retry checks** also rechecks it. + Launching saves the requested lobby IDs. Provisioning selects a host and atomically reserves UDP/TCP ports when capacity is available. An available Plutonium key from the game pool is reserved for each launch. Its assignment survives stops and rebuilds and ends after confirmed removal. diff --git a/docs/guides/provisioning.md b/docs/guides/provisioning.md index 7603258..af9edd6 100644 --- a/docs/guides/provisioning.md +++ b/docs/guides/provisioning.md @@ -7,6 +7,13 @@ Provisioning owns the queues, safety checks, execution, and recovery for all six actions. Create a server using **Create server**. For an existing server, choose **Manage server** or open its **Server actions** menu. +**Create server** opens one dialog with game and map choices followed by automatic +readiness checks. Blockers explain what needs attention before launch. Runtime +and network overrides are under **Advanced settings**; image, configuration, +managed files, upload time, and internal server ID are under **Setup details**. +The footer keeps **Cancel** and **Launch server** available while reviewing the +setup. Create and launch reusable lineups separately in **Fleet → Collections**. + | Action | Behavior | | --- | --- | | Create | Create a new instance using the latest promoted files and verify container health, game status, the selected map, and the Plutonium revision. | @@ -164,8 +171,9 @@ Create and rebuild capture a setup when queued and select the latest promoted files again when creation begins. The page distinguishes **Setup captured when queued** from **Files selected for this attempt**. A queued capture does not pin versions; an attempted selection is not proof that every file was installed. -**Launch another server** uses the latest promoted setup with a fresh identity and -port, rather than repairing or replaying the recorded job. +**Launch another server** opens the creation dialog with the recorded game and +map as editable starting values. It checks the latest promoted setup and creates +a separate server with a fresh identity and port. ### History and refresh behavior diff --git a/src/lib/ui/components/DeployServerDialog.svelte b/src/lib/ui/components/DeployServerDialog.svelte index 554a1b0..77c349c 100644 --- a/src/lib/ui/components/DeployServerDialog.svelte +++ b/src/lib/ui/components/DeployServerDialog.svelte @@ -1,198 +1,249 @@ - { if (isDialogBackdrop(event, dialog) && !busy && !saving) open = false; }} oncancel={event => { if (busy || saving) event.preventDefault(); }} onclose={() => open = false}> -
-

{preview ? 'Review server launch' : 'Create server'}

-
  1. 01 Choose a starting point
  2. 02 Review & launch
- {#if error}{/if} - {#if selectionNotice}

{selectionNotice}

{/if} - {#if open && loaded && (sourceDeploymentId || !selectedBlueprint)}{/if} - {#if !loaded} -

{error ? 'Your launch choices could not be loaded.' : 'Loading your maps and collections…'}

- {#if error}{/if} - {:else if preview} - {#if lobbyNotice}

{lobbyNotice}

{/if} - {#if expired}

This preview expired. Refresh it to recheck capacity and key availability.

{/if} -
{#if preview.blueprintName}

From collection: {preview.blueprintName}

{/if}
-

Before you launch

-
    {#each preview.checks as check}
  • {check.state === 'pass' ? '✓' : check.state === 'blocked' ? '!' : 'i'}
    {check.label}

    {check.detail}

  • {/each}
-
-
{ event.preventDefault(); void launch(); }}> -
Your new server{preview.name ?? 'Waiting for an available lobby'}

An available key from this game’s pool is assigned when you launch.

- {#if preview.hasSavedKey}

Game key available

- {:else}

No unused {preview.recipe.game.toUpperCase()} keys are available. Add a key in Keys (opens in a new tab), then Refresh preview to enable launch.

{/if} -
-

Preview valid until {$timeFormat.minuteTime(preview.expiresAt)}. Capacity and key availability are checked again at launch. The host and port are assigned automatically.

-
-
Make this a reusable collection

Save this game and map with a target of one lobby. Future launches use the latest promoted setup and create only missing lobbies.

- {#if savedName}

“{savedName}” is ready in your collection library.

{:else} -
{ event.preventDefault(); void save(); }}>
- {/if} -
- {:else if sourceDeploymentId} -

Launch the latest promoted {game.toUpperCase()} setup, with a fresh lobby and port.

+ +
+ {#if loadingError} + + + {:else if !loaded} +

Loading maps…

{:else} -
{ event.preventDefault(); void review(); }}> - - {#if selectedBlueprint}

{selectedBlueprint.totalServers} target server{selectedBlueprint.totalServers === 1 ? '' : 's'}

{#each selectedBlueprint.entries as entry}

{entry.count}× {entry.mapName} · {entry.game.toUpperCase()}

{/each}

Review missing servers and game key availability together. Every new server uses the latest promoted scripts, plugins, and configuration.

{#if selectedBlueprint.description}

{selectedBlueprint.description}

{/if}
- {:else}

Pick a map to review its current promoted scripts, plugins, and configuration. You can save the map as a collection for future launches.

{/if} -
Know what you’re launching.

Review the current promoted setup, available lobby, and launch prerequisites before creating your server.

-
-
+
+ + +
+ +
+ {preview?.name ? serverTitle(preview.name) : maps.find(map => map.slug === mapSlug)?.friendly ?? 'Choose a map'} +

Uses the current production setup when the server starts.

+
+ {#if checking}Checking availability… + {:else} + {hostStatus.missingGames.length ? 'Game files missing' : hostStatus.filesReady ? 'Game files ready' : 'Game files preparing'} + {#if preview?.hasSavedKey}Key available{/if} + Automatic host & port + {/if} +
+
+ + {#if launchError}{/if} + {#if missingKey}

No unused {game === 't5' ? 'Black Ops I' : 'Black Ops II'} keys are available.

Manage keys ↗
{/if} + {#if hostStatus.error} + {:else if !hostStatus.loading && hostStatus.missingGames.length}

Upload game files before launching.

Manage game files ↗
+ {:else if !hostStatus.loading && !imageValid}

Enter a valid runtime image in Advanced settings.

+ {:else if view.error && hostStatus.hasHosts}{/if} + {#if !hostStatus.loading && !hostStatus.error && !hostStatus.hasHosts}

Add a host to run your server.

Manage hosts ↗
{/if} + {#each checksBlocked as check}

{check.detail}

{/each} + {#if !checking && !unconfirmedLaunch && (view.error || hostStatus.error || missingKey || checksBlocked.length)}{/if} + + {#if open}{/if} + + {#if preview}
+ Setup details +
+
Server ID
{preview.name ?? 'No lobby available'}
+
Configuration
{preview.recipe.configSource}
+
Container image
{preview.recipe.image}
+ {#if hostStatus.uploadedAt}
Game files uploaded
{$timeFormat.dateTime(hostStatus.uploadedAt)}
{/if} +
+

{preview.recipe.files.length} managed files

+
    {#each preview.recipe.files as file}
  • {file.name}{file.version === null ? 'Generated' : `v${file.version}`} · {file.kind}
    {file.path}
  • {/each}
+
{/if} {/if}
-
- + {#snippet actions()} +
+

{footerStatus}

+
+
+ {/snippet} +
diff --git a/src/lib/ui/components/NativeHostSelection.svelte b/src/lib/ui/components/NativeHostSelection.svelte index 0949e81..322ed2e 100644 --- a/src/lib/ui/components/NativeHostSelection.svelte +++ b/src/lib/ui/components/NativeHostSelection.svelte @@ -1,22 +1,34 @@ +{#snippet runtimeFields()} + + {#if compact}

Custom images must be prepared on a host before it can run this server.

{/if} +{/snippet} + +{#snippet networkFields()} + + + +

Public mapping does not change the local Docker port reservation.

+{/snippet} + +{#if compact} +
+ Advanced settings +
+ {@render runtimeFields()} + {#if network}
Network overrides{@render networkFields()}
{/if} +
+
+{:else}

Host and game files

{#if error}{/if} @@ -57,11 +114,17 @@ {/each}{/if}

Hosts keep the latest T5 and T6 files ready automatically. Upload game files when they change. Existing servers keep their current files.

-
Advanced: runtime override

The configured default runtime is prepared automatically. Custom images must first be prepared by an admin from the destination host page.

- {#if network}
Network overrides

Public mapping does not change the local Docker port reservation.

{/if} +
Advanced: runtime override

The configured default runtime is prepared automatically. Custom images must first be prepared by an admin from the destination host page.

{@render runtimeFields()}
+ {#if network}
Network overrides{@render networkFields()}
{/if}
+{/if} diff --git a/src/lib/ui/native-game-files.ts b/src/lib/ui/native-game-files.ts index 500069f..ad66b2b 100644 --- a/src/lib/ui/native-game-files.ts +++ b/src/lib/ui/native-game-files.ts @@ -1,7 +1,17 @@ import type { GameLibrary, GameLibraryGame, GameLibraryVersion } from '../game-files.js'; +import type { ZombieGame } from '../map-aliases.js'; export type LatestGameFiles = Partial>; +export interface NativeHostSelectionStatus { + loading: boolean; + error: string; + hasHosts: boolean; + filesReady: boolean; + missingGames: ZombieGame[]; + uploadedAt: string | null; +} + /** Use the controller's latest upload, even while that upload is still syncing to a host. */ export function latestGameFiles(libraries: GameLibrary[]): LatestGameFiles { const latest: LatestGameFiles = {}; diff --git a/src/lib/ui/server-launch.ts b/src/lib/ui/server-launch.ts new file mode 100644 index 0000000..6182c78 --- /dev/null +++ b/src/lib/ui/server-launch.ts @@ -0,0 +1,130 @@ +import type { LaunchPreview } from '../deployment-plan.js'; +import type { ZombieGame } from '../map-aliases.js'; + +export interface ServerLaunchInput { + game: ZombieGame; + mapSlug: string; + libraryId?: string; + image: string; + bindAddress?: string; + publicAddress?: string; + publicPort?: number; +} + +export interface ServerLaunchState { + preview: LaunchPreview | null; + checking: boolean; + error: string; +} + +/** Keep only a current preview available while launch selections and prerequisites change. */ +export function createServerLaunchController( + onChange: (state: ServerLaunchState) => void, + dependencies: { fetch?: typeof fetch; debounceMs?: number } = {}, +) { + const fetchPreview = dependencies.fetch ?? ((...args: Parameters) => fetch(...args)); + const debounceMs = dependencies.debounceMs ?? 200; + let selection: string | null = null; + let state: ServerLaunchState = { preview: null, checking: false, error: '' }; + let revision = 0; + let timer: ReturnType | undefined; + let request: AbortController | undefined; + let suspended = false; + let disposed = false; + + function publish(next: ServerLaunchState) { + if (state.preview === next.preview && state.checking === next.checking && state.error === next.error) return; + state = next; + onChange(next); + } + + function cancel() { + revision++; + clearTimeout(timer); + timer = undefined; + request?.abort(); + request = undefined; + } + + async function check(current: number) { + if (disposed || suspended || current !== revision || !selection) return; + timer = undefined; + const controller = new AbortController(); + request = controller; + try { + const response = await fetchPreview('/api/provisioning/preview', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: selection, + signal: controller.signal, + }); + const body = await response.json(); + if (disposed || suspended || current !== revision) return; + if (!response.ok) throw new Error(body.error ?? 'Could not check launch readiness. Try again.'); + const preview = body as LaunchPreview; + const expiresAt = new Date(preview.expiresAt).getTime(); + if (!Number.isFinite(expiresAt) || expiresAt <= Date.now()) { + throw new Error('The launch check expired. Try again.'); + } + publish({ preview, checking: false, error: '' }); + if (!disposed && !suspended && current === revision) { + timer = setTimeout(refresh, Math.min(expiresAt - Date.now(), 2_147_483_647)); + } + } catch (error) { + if (!disposed && !suspended && current === revision) { + publish({ preview: null, checking: false, + error: error instanceof Error ? error.message : 'Could not check launch readiness. Try again.' }); + } + } finally { + if (current === revision) request = undefined; + } + } + + function refresh() { + if (disposed || !selection || (state.checking && !suspended)) return; + suspended = false; + cancel(); + publish({ preview: null, checking: true, error: '' }); + void check(revision); + } + + function clear() { + if (disposed) return; + cancel(); + selection = null; + suspended = false; + publish({ preview: null, checking: false, error: '' }); + } + + return { + select(input: ServerLaunchInput | null) { + if (disposed) return; + if (!input) { clear(); return; } + // Explicit fields keep identity stable and exclude unrelated launch or collection metadata. + const next = JSON.stringify({ game: input.game, mapSlug: input.mapSlug, libraryId: input.libraryId, + image: input.image, bindAddress: input.bindAddress || undefined, + publicAddress: input.publicAddress || undefined, publicPort: input.publicPort }); + if (selection === next) return; + cancel(); + selection = next; + suspended = false; + publish({ preview: null, checking: true, error: '' }); + const current = revision; + timer = setTimeout(() => void check(current), debounceMs); + }, + refresh, + clear, + /** Freeze a settled preview during submission. refresh() resumes checks after a failed launch. */ + suspend() { + if (disposed) return; + suspended = true; + cancel(); + publish({ ...state, checking: false }); + }, + dispose() { + cancel(); + selection = null; + disposed = true; + }, + }; +} diff --git a/tests/unit/ui-server-launch.test.ts b/tests/unit/ui-server-launch.test.ts new file mode 100644 index 0000000..f6eaa44 --- /dev/null +++ b/tests/unit/ui-server-launch.test.ts @@ -0,0 +1,192 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { LaunchPreview } from '../../src/lib/deployment-plan.js'; +import { createServerLaunchController, type ServerLaunchInput, type ServerLaunchState } from '../../src/lib/ui/server-launch.js'; + +const selection: ServerLaunchInput = { game: 't5', mapSlug: 'kino', libraryId: 'library-1', image: 'runtime:latest' }; +const response = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status }); +const preview = (id = 'preview-1', expiresIn = 60_000): LaunchPreview => ({ + id, expiresAt: new Date(Date.now() + expiresIn).toISOString(), name: 't5_zombie_theater_1', + port: null, hasSavedKey: true, blueprintName: null, checks: [], ready: true, + recipe: { game: 't5', mapSlug: 'kino', mapName: 'Kino Der Toten', expectedMap: 'zombie_theater', + capturedAt: new Date().toISOString(), image: 'runtime:latest', configFile: 'server.cfg', + configSource: 'T5 Zombies', files: [], fingerprint: 'recipe-1' }, +}); + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((done, fail) => { resolve = done; reject = fail; }); + return { promise, resolve, reject }; +} + +let controller: ReturnType; +let state: ServerLaunchState; +let fetchPreview: ReturnType>; +let changes: ReturnType void>>; + +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-10-07T18:00:00Z')); + state = { preview: null, checking: false, error: '' }; + fetchPreview = vi.fn(); + changes = vi.fn((next: ServerLaunchState) => { state = next; }); + controller = createServerLaunchController(changes, { fetch: fetchPreview }); +}); +afterEach(() => { controller.dispose(); vi.useRealTimers(); vi.restoreAllMocks(); }); + +describe('automatic server launch checks', () => { + it('debounces rapid selections and sends only the final game, map, and native overrides', async () => { + fetchPreview.mockResolvedValue(response(preview())); + controller.select(selection); + expect(state).toEqual({ preview: null, checking: true, error: '' }); + await vi.advanceTimersByTimeAsync(150); + controller.select({ ...selection, mapSlug: 'five', publicPort: 30100 }); + await vi.advanceTimersByTimeAsync(199); + expect(fetchPreview).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + expect(fetchPreview).toHaveBeenCalledTimes(1); + expect(fetchPreview.mock.calls[0][0]).toBe('/api/provisioning/preview'); + expect(JSON.parse(fetchPreview.mock.calls[0][1]!.body as string)).toEqual({ ...selection, mapSlug: 'five', publicPort: 30100 }); + expect(state.preview?.id).toBe('preview-1'); + expect(state.checking).toBe(false); + }); + + it('invalidates a settled preview synchronously when the selection changes', async () => { + fetchPreview.mockResolvedValue(response(preview())); + controller.select(selection); + await vi.advanceTimersByTimeAsync(200); + expect(state.preview).not.toBeNull(); + controller.select({ ...selection, image: 'runtime:other' }); + expect(state).toEqual({ preview: null, checking: true, error: '' }); + }); + + it('does not duplicate requests for the same selection or overlapping refreshes', async () => { + const request = deferred(); + fetchPreview.mockReturnValue(request.promise); + controller.select(selection); + controller.select({ ...selection }); + await vi.advanceTimersByTimeAsync(200); + controller.refresh(); + controller.refresh(); + controller.select({ ...selection, publicAddress: '' }); + expect(fetchPreview).toHaveBeenCalledTimes(1); + request.resolve(response(preview())); + await vi.advanceTimersByTimeAsync(0); + controller.select({ ...selection }); + await vi.advanceTimersByTimeAsync(200); + expect(fetchPreview).toHaveBeenCalledTimes(1); + expect(state.preview?.id).toBe('preview-1'); + }); + + it.each(['success', 'error'] as const)('aborts changed requests and ignores stale %s responses', async (result) => { + const first = deferred(); + fetchPreview.mockReturnValueOnce(first.promise).mockResolvedValueOnce(response(preview('new-preview'))); + controller.select(selection); + await vi.advanceTimersByTimeAsync(200); + const signal = fetchPreview.mock.calls[0][1]!.signal!; + controller.select({ ...selection, mapSlug: 'five' }); + expect(signal.aborted).toBe(true); + await vi.advanceTimersByTimeAsync(200); + if (result === 'success') first.resolve(response(preview('old-preview'))); + else first.reject(new Error('Old network error')); + await vi.advanceTimersByTimeAsync(0); + expect(state.preview?.id).toBe('new-preview'); + expect(state.error).toBe(''); + expect(state.checking).toBe(false); + }); + + it('ignores a response from a dismissed session after reopening the same selection', async () => { + const first = deferred(); + fetchPreview.mockReturnValueOnce(first.promise).mockResolvedValueOnce(response(preview('reopened-preview'))); + controller.select(selection); + await vi.advanceTimersByTimeAsync(200); + controller.clear(); + expect(state).toEqual({ preview: null, checking: false, error: '' }); + expect(fetchPreview.mock.calls[0][1]!.signal!.aborted).toBe(true); + controller.select(selection); + await vi.advanceTimersByTimeAsync(200); + first.resolve(response(preview('dismissed-preview'))); + await vi.advanceTimersByTimeAsync(0); + expect(state.preview?.id).toBe('reopened-preview'); + }); + + it('cancels a pending debounce when prerequisite data becomes unavailable', async () => { + controller.select(selection); + controller.select(null); + await vi.advanceTimersByTimeAsync(1000); + expect(fetchPreview).not.toHaveBeenCalled(); + expect(state).toEqual({ preview: null, checking: false, error: '' }); + }); + + it('refreshes immediately and never retains an old preview if the refresh fails', async () => { + const refresh = deferred(); + fetchPreview.mockResolvedValueOnce(response(preview())).mockReturnValueOnce(refresh.promise); + controller.select(selection); + await vi.advanceTimersByTimeAsync(200); + controller.refresh(); + expect(fetchPreview).toHaveBeenCalledTimes(2); + expect(state).toEqual({ preview: null, checking: true, error: '' }); + refresh.resolve(response({ error: 'No runtime is available.' }, 409)); + await vi.advanceTimersByTimeAsync(0); + expect(state).toEqual({ preview: null, checking: false, error: 'No runtime is available.' }); + fetchPreview.mockResolvedValueOnce(response(preview('retry-preview'))); + controller.refresh(); + expect(state.error).toBe(''); + await vi.advanceTimersByTimeAsync(0); + expect(state.preview?.id).toBe('retry-preview'); + }); + + it('automatically renews an expired preview and removes it while checking', async () => { + const renewal = deferred(); + fetchPreview.mockResolvedValueOnce(response(preview('initial', 1000))).mockReturnValueOnce(renewal.promise); + controller.select(selection); + await vi.advanceTimersByTimeAsync(999); + expect(state.preview?.id).toBe('initial'); + await vi.advanceTimersByTimeAsync(1); + expect(state).toEqual({ preview: null, checking: true, error: '' }); + expect(fetchPreview).toHaveBeenCalledTimes(2); + renewal.resolve(response(preview('renewed'))); + await vi.advanceTimersByTimeAsync(0); + expect(state.preview?.id).toBe('renewed'); + }); + + it('rejects an already expired response without entering a refresh loop', async () => { + fetchPreview.mockResolvedValue(response(preview('expired', 100))); + controller.select(selection); + await vi.advanceTimersByTimeAsync(2000); + expect(fetchPreview).toHaveBeenCalledTimes(1); + expect(state).toEqual({ preview: null, checking: false, error: 'The launch check expired. Try again.' }); + }); + + it('freezes a preview during submission and resumes checks after a failed launch', async () => { + fetchPreview.mockResolvedValueOnce(response(preview('submitted', 1000))); + controller.select(selection); + await vi.advanceTimersByTimeAsync(200); + controller.suspend(); + controller.select({ ...selection }); + await vi.advanceTimersByTimeAsync(1000); + expect(state.preview?.id).toBe('submitted'); + expect(fetchPreview).toHaveBeenCalledTimes(1); + fetchPreview.mockResolvedValueOnce(response(preview('retry'))); + controller.refresh(); + expect(state.preview).toBeNull(); + await vi.advanceTimersByTimeAsync(0); + expect(state.preview?.id).toBe('retry'); + }); + + it('ignores in-flight responses and future input after disposal', async () => { + const request = deferred(); + fetchPreview.mockReturnValueOnce(request.promise); + controller.select(selection); + await vi.advanceTimersByTimeAsync(200); + const previousChanges = changes.mock.calls.length; + controller.dispose(); + request.resolve(response(preview())); + controller.select({ ...selection, mapSlug: 'five' }); + controller.refresh(); + await vi.advanceTimersByTimeAsync(60_000); + expect(changes).toHaveBeenCalledTimes(previousChanges); + expect(fetchPreview).toHaveBeenCalledTimes(1); + expect(fetchPreview.mock.calls[0][1]!.signal!.aborted).toBe(true); + }); +});