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