Files
electric-cherry/tests/unit/ui-server-launch.test.ts
T

249 lines
10 KiB
TypeScript

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<T>() {
let resolve!: (value: T) => void;
let reject!: (error: Error) => void;
const promise = new Promise<T>((done, fail) => {
resolve = done;
reject = fail;
});
return { promise, resolve, reject };
}
let controller: ReturnType<typeof createServerLaunchController>;
let state: ServerLaunchState;
let fetchPreview: ReturnType<typeof vi.fn<typeof fetch>>;
let changes: ReturnType<typeof vi.fn<(state: ServerLaunchState) => void>>;
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-10-07T18:00:00Z'));
state = { preview: null, checking: false, error: '' };
fetchPreview = vi.fn<typeof fetch>();
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<Response>();
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<Response>();
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<Response>();
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<Response>();
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<Response>();
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<Response>();
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);
});
});