Add Prettier and ESLint tooling (Refs #77)

This commit is contained in:
2026-10-09 15:56:13 -04:00
parent 8ed059b8d3
commit 16469e2d43
711 changed files with 99737 additions and 28286 deletions
+151 -53
View File
@@ -1,25 +1,60 @@
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';
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 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'],
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,
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; });
const promise = new Promise<T>((done, fail) => {
resolve = done;
reject = fail;
});
return { promise, resolve, reject };
}
@@ -33,10 +68,16 @@ beforeEach(() => {
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; });
changes = vi.fn((next: CollectionLaunchState) => {
state = next;
});
controller = createCollectionLaunchController(changes, { fetch: fetchPreview });
});
afterEach(() => { controller.dispose(); vi.useRealTimers(); vi.restoreAllMocks(); });
afterEach(() => {
controller.dispose();
vi.useRealTimers();
vi.restoreAllMocks();
});
async function settle(value = preview(), input = selection) {
fetchPreview.mockResolvedValueOnce(response(value));
@@ -50,14 +91,20 @@ describe('automatic collection launch checks', () => {
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' } });
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' },
...selection,
mode: 'fill',
libraryIds: { t5: 'library-1', t6: 'library-2' }
});
expect(state.preview?.mode).toBe('fill');
expect(state.checking).toBe(false);
@@ -90,27 +137,32 @@ describe('automatic collection launch checks', () => {
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(['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.' },
{ 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);
@@ -122,9 +174,20 @@ describe('automatic collection launch checks', () => {
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 }],
})));
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/);
@@ -132,7 +195,9 @@ describe('automatic collection launch checks', () => {
it('warns when retained identities change and gives removal changes priority', async () => {
await settle();
fetchPreview.mockResolvedValueOnce(response(preview({ retainedServers: ['t5_zombie_theater_5'] })));
fetchPreview.mockResolvedValueOnce(
response(preview({ retainedServers: ['t5_zombie_theater_5'] }))
);
controller.refresh();
await vi.advanceTimersByTimeAsync(0);
expect(state.changeNotice).toMatch(/Servers to keep changed/);
@@ -145,26 +210,47 @@ describe('automatic collection launch checks', () => {
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 }],
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 })),
})));
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.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();
@@ -182,11 +268,18 @@ describe('automatic collection launch checks', () => {
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));
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: '' });
expect(state).toEqual({
preview: null,
checking: false,
error: 'Host information unavailable.',
changeNotice: ''
});
fetchPreview.mockResolvedValueOnce(response(preview({ removals: [] })));
controller.refresh();
expect(state.error).toBe('');
@@ -212,7 +305,12 @@ describe('automatic collection launch checks', () => {
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: '' });
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 () => {