351 lines
14 KiB
TypeScript
351 lines
14 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import type { CollectionPreview } from '../../src/lib/deployment-plan.js';
|
|
import {
|
|
createCollectionLaunchController,
|
|
type CollectionLaunchInput,
|
|
type CollectionLaunchState
|
|
} from '../../src/lib/ui/collection-launch.js';
|
|
|
|
const selection: CollectionLaunchInput = {
|
|
blueprintId: 'collection-1',
|
|
mode: 'exact',
|
|
libraryIds: { t5: 'library-1' },
|
|
image: 'runtime:latest'
|
|
};
|
|
const response = (body: unknown, status = 200) => new Response(JSON.stringify(body), { status });
|
|
const preview = (overrides: Partial<CollectionPreview> = {}): CollectionPreview => ({
|
|
id: 'preview-1',
|
|
expiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
blueprintName: 'Zombies',
|
|
mode: 'exact',
|
|
launchConcurrency: 2,
|
|
retainedServers: ['t5_zombie_theater_1'],
|
|
removals: [{ name: 't5_zombie_theater_3', reason: 'Exceeds the collection target.' }],
|
|
servers: [{ name: 't5_zombie_theater_2', port: null, entryIndex: 0, hasSavedKey: true }],
|
|
blockers: [],
|
|
ready: true,
|
|
entries: [
|
|
{
|
|
count: 2,
|
|
existing: 1,
|
|
missing: 1,
|
|
existingServers: ['t5_zombie_theater_1'],
|
|
coverage: [],
|
|
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'
|
|
}
|
|
}
|
|
],
|
|
...overrides
|
|
});
|
|
|
|
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 createCollectionLaunchController>;
|
|
let state: CollectionLaunchState;
|
|
let fetchPreview: ReturnType<typeof vi.fn<typeof fetch>>;
|
|
let changes: ReturnType<typeof vi.fn<(state: CollectionLaunchState) => void>>;
|
|
|
|
beforeEach(() => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date('2026-10-07T18:00:00Z'));
|
|
state = { preview: null, checking: false, error: '', changeNotice: '' };
|
|
fetchPreview = vi.fn<typeof fetch>();
|
|
changes = vi.fn((next: CollectionLaunchState) => {
|
|
state = next;
|
|
});
|
|
controller = createCollectionLaunchController(changes, { fetch: fetchPreview });
|
|
});
|
|
afterEach(() => {
|
|
controller.dispose();
|
|
vi.useRealTimers();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
async function settle(value = preview(), input = selection) {
|
|
fetchPreview.mockResolvedValueOnce(response(value));
|
|
controller.select(input);
|
|
await vi.advanceTimersByTimeAsync(200);
|
|
}
|
|
|
|
describe('automatic collection launch checks', () => {
|
|
it('debounces selections and checks only the final collection mode and host inputs', async () => {
|
|
fetchPreview.mockResolvedValueOnce(response(preview({ mode: 'fill' })));
|
|
controller.select(selection);
|
|
expect(state).toEqual({ preview: null, checking: true, error: '', changeNotice: '' });
|
|
await vi.advanceTimersByTimeAsync(150);
|
|
controller.select({
|
|
...selection,
|
|
mode: 'fill',
|
|
libraryIds: { t6: 'library-2', t5: 'library-1' }
|
|
});
|
|
await vi.advanceTimersByTimeAsync(199);
|
|
expect(fetchPreview).not.toHaveBeenCalled();
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
expect(fetchPreview).toHaveBeenCalledTimes(1);
|
|
expect(fetchPreview.mock.calls[0][0]).toBe('/api/deployment-collections/preview');
|
|
expect(JSON.parse(fetchPreview.mock.calls[0][1]!.body as string)).toEqual({
|
|
...selection,
|
|
mode: 'fill',
|
|
libraryIds: { t5: 'library-1', t6: 'library-2' }
|
|
});
|
|
expect(state.preview?.mode).toBe('fill');
|
|
expect(state.checking).toBe(false);
|
|
expect(state.changeNotice).toBe('');
|
|
});
|
|
|
|
it('invalidates a settled preview synchronously when the runtime changes', async () => {
|
|
await settle();
|
|
expect(state.preview).not.toBeNull();
|
|
controller.select({ ...selection, image: 'runtime:other' });
|
|
expect(state).toEqual({ preview: null, checking: true, error: '', changeNotice: '' });
|
|
});
|
|
|
|
it('deduplicates identical selections and active checks, and refreshes a debounce immediately', async () => {
|
|
const request = deferred<Response>();
|
|
fetchPreview.mockReturnValueOnce(request.promise);
|
|
const libraries = { t5: 'library-1', t6: 'library-2' };
|
|
controller.select({ ...selection, libraryIds: libraries });
|
|
controller.refresh();
|
|
expect(fetchPreview).toHaveBeenCalledTimes(1);
|
|
controller.select({ ...selection, libraryIds: { t6: 'library-2', t5: 'library-1' } });
|
|
controller.refresh();
|
|
controller.refresh();
|
|
await vi.advanceTimersByTimeAsync(200);
|
|
expect(fetchPreview).toHaveBeenCalledTimes(1);
|
|
request.resolve(response(preview()));
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
controller.select({ ...selection, libraryIds: libraries });
|
|
await vi.advanceTimersByTimeAsync(200);
|
|
expect(fetchPreview).toHaveBeenCalledTimes(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({ id: 'new', mode: 'fill' })));
|
|
controller.select(selection);
|
|
await vi.advanceTimersByTimeAsync(200);
|
|
const signal = fetchPreview.mock.calls[0][1]!.signal!;
|
|
controller.select({ ...selection, mode: 'fill' });
|
|
expect(signal.aborted).toBe(true);
|
|
await vi.advanceTimersByTimeAsync(200);
|
|
if (result === 'success') first.resolve(response(preview({ id: 'old' })));
|
|
else first.reject(new Error('Old request failed'));
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
expect(state.preview?.id).toBe('new');
|
|
expect(state.error).toBe('');
|
|
expect(state.changeNotice).toBe('');
|
|
expect(state.checking).toBe(false);
|
|
}
|
|
);
|
|
|
|
it.each([
|
|
{ name: 't5_zombie_theater_4', reason: 'Exceeds the collection target.' },
|
|
{ name: 't5_zombie_theater_3', reason: 'Map is outside this collection.' }
|
|
])('warns when a removal name or reason changes across a host refresh: %j', async (removal) => {
|
|
await settle();
|
|
controller.select(null);
|
|
expect(state).toEqual({ preview: null, checking: false, error: '', changeNotice: '' });
|
|
await settle(preview({ id: 'refreshed', removals: [removal] }));
|
|
expect(state.changeNotice).toMatch(/Servers to remove changed/);
|
|
expect(state.preview?.id).toBe('refreshed');
|
|
});
|
|
|
|
it('warns when creation identities change without changing the number of servers', async () => {
|
|
await settle();
|
|
fetchPreview.mockResolvedValueOnce(
|
|
response(
|
|
preview({
|
|
servers: [
|
|
{
|
|
name: 't5_zombie_theater_4',
|
|
port: null,
|
|
entryIndex: 0,
|
|
hasSavedKey: true
|
|
}
|
|
]
|
|
})
|
|
)
|
|
);
|
|
controller.refresh();
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
expect(state.changeNotice).toMatch(/Servers to create changed/);
|
|
});
|
|
|
|
it('warns when retained identities change and gives removal changes priority', async () => {
|
|
await settle();
|
|
fetchPreview.mockResolvedValueOnce(
|
|
response(preview({ retainedServers: ['t5_zombie_theater_5'] }))
|
|
);
|
|
controller.refresh();
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
expect(state.changeNotice).toMatch(/Servers to keep changed/);
|
|
fetchPreview.mockResolvedValueOnce(response(preview({ removals: [] })));
|
|
controller.refresh();
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
expect(state.changeNotice).toMatch(/Servers to remove changed/);
|
|
});
|
|
|
|
it('does not report changed plans for reordering or changes in readiness alone', async () => {
|
|
const original = preview({
|
|
retainedServers: ['t5_zombie_theater_1', 't5_zombie_five_1'],
|
|
removals: [
|
|
{ name: 't5_zombie_theater_3', reason: 'Excess.' },
|
|
{ name: 't5_zombie_theater_4', reason: 'Excess.' }
|
|
],
|
|
servers: [
|
|
{ name: 't5_zombie_theater_2', port: null, entryIndex: 0, hasSavedKey: false },
|
|
{ name: 't5_zombie_theater_5', port: null, entryIndex: 0, hasSavedKey: false }
|
|
]
|
|
});
|
|
await settle(original);
|
|
fetchPreview.mockResolvedValueOnce(
|
|
response(
|
|
preview({
|
|
retainedServers: [...original.retainedServers].reverse(),
|
|
removals: [...original.removals].reverse(),
|
|
servers: [...original.servers]
|
|
.reverse()
|
|
.map((server) => ({ ...server, hasSavedKey: true }))
|
|
})
|
|
)
|
|
);
|
|
controller.refresh();
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
expect(state.changeNotice).toBe('');
|
|
});
|
|
|
|
it.each(['blueprint', 'mode'] as const)(
|
|
'starts a fresh comparison when the %s changes',
|
|
async (change) => {
|
|
await settle();
|
|
const input =
|
|
change === 'mode'
|
|
? { ...selection, mode: 'fill' as const }
|
|
: { ...selection, blueprintId: 'other' };
|
|
await settle(
|
|
preview({ mode: input.mode, removals: [], retainedServers: [], servers: [] }),
|
|
input
|
|
);
|
|
expect(state.changeNotice).toBe('');
|
|
}
|
|
);
|
|
|
|
it('clears history and ignores a dismissed response when reopening the same collection', async () => {
|
|
await settle();
|
|
const dismissed = deferred<Response>();
|
|
fetchPreview.mockReturnValueOnce(dismissed.promise);
|
|
controller.refresh();
|
|
controller.clear();
|
|
expect(fetchPreview.mock.calls[1][1]!.signal!.aborted).toBe(true);
|
|
await settle(preview({ id: 'reopened', removals: [], retainedServers: [], servers: [] }));
|
|
dismissed.resolve(response(preview({ id: 'dismissed' })));
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
expect(state.preview?.id).toBe('reopened');
|
|
expect(state.changeNotice).toBe('');
|
|
});
|
|
|
|
it('keeps failed refreshes unlaunchable and compares a successful retry with the last settled plan', async () => {
|
|
await settle();
|
|
fetchPreview.mockResolvedValueOnce(
|
|
response({ error: 'Host information unavailable.' }, 503)
|
|
);
|
|
controller.refresh();
|
|
expect(state.preview).toBeNull();
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
expect(state).toEqual({
|
|
preview: null,
|
|
checking: false,
|
|
error: 'Host information unavailable.',
|
|
changeNotice: ''
|
|
});
|
|
fetchPreview.mockResolvedValueOnce(response(preview({ removals: [] })));
|
|
controller.refresh();
|
|
expect(state.error).toBe('');
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
expect(state.changeNotice).toMatch(/Servers to remove changed/);
|
|
});
|
|
|
|
it('automatically renews expired plans and compares the updated removal list', async () => {
|
|
await settle(preview({ expiresAt: new Date(Date.now() + 1000).toISOString() }));
|
|
const renewal = deferred<Response>();
|
|
fetchPreview.mockReturnValueOnce(renewal.promise);
|
|
await vi.advanceTimersByTimeAsync(799);
|
|
expect(state.preview).not.toBeNull();
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
expect(state).toEqual({ preview: null, checking: true, error: '', changeNotice: '' });
|
|
renewal.resolve(response(preview({ id: 'renewed', removals: [] })));
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
expect(state.preview?.id).toBe('renewed');
|
|
expect(state.changeNotice).toMatch(/Servers to remove changed/);
|
|
});
|
|
|
|
it('rejects expired responses without repeatedly retrying them', async () => {
|
|
await settle(preview({ expiresAt: new Date(Date.now() + 100).toISOString() }));
|
|
await vi.advanceTimersByTimeAsync(2000);
|
|
expect(fetchPreview).toHaveBeenCalledTimes(1);
|
|
expect(state).toEqual({
|
|
preview: null,
|
|
checking: false,
|
|
error: 'The collection check expired. Try again.',
|
|
changeNotice: ''
|
|
});
|
|
});
|
|
|
|
it('preserves the same preview ID while an apply is unconfirmed and resumes after definite rejection', async () => {
|
|
await settle(preview({ expiresAt: new Date(Date.now() + 1000).toISOString() }));
|
|
controller.suspend();
|
|
controller.select({ ...selection });
|
|
await vi.advanceTimersByTimeAsync(2000);
|
|
expect(state.preview?.id).toBe('preview-1');
|
|
expect(fetchPreview).toHaveBeenCalledTimes(1);
|
|
fetchPreview.mockResolvedValueOnce(response(preview({ id: 'retry', removals: [] })));
|
|
controller.refresh();
|
|
expect(state.preview).toBeNull();
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
expect(state.preview?.id).toBe('retry');
|
|
expect(state.changeNotice).toMatch(/Servers to remove changed/);
|
|
});
|
|
|
|
it('cancels pending selections and prevents disposed responses from publishing', async () => {
|
|
controller.select(selection);
|
|
controller.select(null);
|
|
await vi.advanceTimersByTimeAsync(200);
|
|
expect(fetchPreview).not.toHaveBeenCalled();
|
|
const request = deferred<Response>();
|
|
fetchPreview.mockReturnValueOnce(request.promise);
|
|
controller.select(selection);
|
|
await vi.advanceTimersByTimeAsync(200);
|
|
const count = changes.mock.calls.length;
|
|
controller.dispose();
|
|
request.resolve(response(preview()));
|
|
controller.select(selection);
|
|
controller.refresh();
|
|
await vi.advanceTimersByTimeAsync(60_000);
|
|
expect(changes).toHaveBeenCalledTimes(count);
|
|
expect(fetchPreview).toHaveBeenCalledTimes(1);
|
|
expect(fetchPreview.mock.calls[0][1]!.signal!.aborted).toBe(true);
|
|
});
|
|
});
|