Add Prettier and ESLint tooling (Refs #77)
This commit is contained in:
@@ -4,7 +4,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { rconConfigured } from '../../src/lib/server/config.js';
|
||||
import { getServer, type ServerRow } from '../../src/lib/server/fleet/servers.js';
|
||||
import { latestGameSubmission } from '../../src/lib/server/fleet/lifecycle-evidence.js';
|
||||
import { rconGetPlayerCount, rconPlutoniumVersion, rconStatusResult } from '../../src/lib/server/integrations/rcon.js';
|
||||
import {
|
||||
rconGetPlayerCount,
|
||||
rconPlutoniumVersion,
|
||||
rconStatusResult
|
||||
} from '../../src/lib/server/integrations/rcon.js';
|
||||
import { nativeInstance, nativeRequest } from '../../src/lib/server/backends/native.js';
|
||||
|
||||
vi.mock('node:dgram', () => ({ createSocket: vi.fn() }));
|
||||
@@ -12,10 +16,18 @@ vi.mock('../../src/lib/server/config.js', () => ({
|
||||
config: { rcon: { host: 'localhost', password: 'test', timeoutMs: 1_000 } },
|
||||
rconConfigured: vi.fn()
|
||||
}));
|
||||
vi.mock('../../src/lib/server/fleet/servers.js', () => ({ getServer: vi.fn(), setServerField: vi.fn() }));
|
||||
vi.mock('../../src/lib/server/fleet/lifecycle-evidence.js', () => ({ latestGameSubmission: vi.fn() }));
|
||||
vi.mock('../../src/lib/server/fleet/servers.js', () => ({
|
||||
getServer: vi.fn(),
|
||||
setServerField: vi.fn()
|
||||
}));
|
||||
vi.mock('../../src/lib/server/fleet/lifecycle-evidence.js', () => ({
|
||||
latestGameSubmission: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('../../src/lib/server/backends/native.js', () => ({ nativeInstance: vi.fn(), nativeRequest: vi.fn() }));
|
||||
vi.mock('../../src/lib/server/backends/native.js', () => ({
|
||||
nativeInstance: vi.fn(),
|
||||
nativeRequest: vi.fn()
|
||||
}));
|
||||
|
||||
class FakeSocket extends EventEmitter {
|
||||
send = vi.fn();
|
||||
@@ -24,14 +36,21 @@ class FakeSocket extends EventEmitter {
|
||||
|
||||
let sockets: FakeSocket[];
|
||||
let server: ServerRow;
|
||||
const packet = (body: string) => Buffer.concat([Buffer.from([255, 255, 255, 255]), Buffer.from(body)]);
|
||||
const reply = (index = 0) => sockets[index].emit('message', packet('statusResponse\n\\mapname\\mp_kino\n10 45 "joe"'));
|
||||
const packet = (body: string) =>
|
||||
Buffer.concat([Buffer.from([255, 255, 255, 255]), Buffer.from(body)]);
|
||||
const reply = (index = 0) =>
|
||||
sockets[index].emit('message', packet('statusResponse\n\\mapname\\mp_kino\n10 45 "joe"'));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.useFakeTimers();
|
||||
sockets = [];
|
||||
server = { name: 'test-server', created_at: '2026-10-01T00:00:00Z', game_port: 28960, rcon_host: null } as ServerRow;
|
||||
server = {
|
||||
name: 'test-server',
|
||||
created_at: '2026-10-01T00:00:00Z',
|
||||
game_port: 28960,
|
||||
rcon_host: null
|
||||
} as ServerRow;
|
||||
vi.mocked(getServer).mockImplementation(() => ({ ...server }));
|
||||
vi.mocked(latestGameSubmission).mockReturnValue(null);
|
||||
vi.mocked(rconConfigured).mockReturnValue(true);
|
||||
@@ -57,7 +76,11 @@ describe('RCON status transport', () => {
|
||||
if (result.ok) throw new Error('Expected an unrecognized reply');
|
||||
expect(result.detail).not.toContain('player');
|
||||
expect(result.detail).not.toContain('192.0.2');
|
||||
const hex = result.rawReply!.split('\n').map(line => line.slice(6, 53).trim()).join(' ').replaceAll(' ', '');
|
||||
const hex = result
|
||||
.rawReply!.split('\n')
|
||||
.map((line) => line.slice(6, 53).trim())
|
||||
.join(' ')
|
||||
.replaceAll(' ', '');
|
||||
const decoded = Buffer.from(hex, 'hex').toString();
|
||||
expect(decoded).toContain('[redacted]');
|
||||
expect(decoded).not.toContain('192.0.2');
|
||||
@@ -70,18 +93,38 @@ describe('RCON status transport', () => {
|
||||
['invalid-reply', 'bad-reply', 'Agent RCON reply is missing']
|
||||
])('preserves the native agent %s diagnosis', async (agentReason, reason, detail) => {
|
||||
server.native_instance_id = 'instance';
|
||||
vi.mocked(nativeInstance).mockReturnValue({ id: 'instance', generation: 1, host_id: 'host', port: 28960 } as never);
|
||||
vi.mocked(nativeInstance).mockReturnValue({
|
||||
id: 'instance',
|
||||
generation: 1,
|
||||
host_id: 'host',
|
||||
port: 28960
|
||||
} as never);
|
||||
vi.mocked(nativeRequest).mockResolvedValue({ ok: false, reason: agentReason, text: '' });
|
||||
expect(await rconStatusResult(server.name)).toMatchObject({ ok: false, reason, detail: expect.stringContaining(detail) });
|
||||
expect(await rconStatusResult(server.name)).toMatchObject({
|
||||
ok: false,
|
||||
reason,
|
||||
detail: expect.stringContaining(detail)
|
||||
});
|
||||
expect(nativeRequest).toHaveBeenCalledOnce();
|
||||
expect(sockets).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('uses the successful native reply after agent-side recovery', async () => {
|
||||
server.native_instance_id = 'instance';
|
||||
vi.mocked(nativeInstance).mockReturnValue({ id: 'instance', generation: 1, host_id: 'host', port: 28960 } as never);
|
||||
vi.mocked(nativeRequest).mockResolvedValue({ ok: true, text: 'map: mp_kino\nnum score ping guid name\n--- ----- ---- ---- ----\n' });
|
||||
expect(await rconStatusResult(server.name)).toMatchObject({ ok: true, status: { players: 0, map: 'mp_kino' } });
|
||||
vi.mocked(nativeInstance).mockReturnValue({
|
||||
id: 'instance',
|
||||
generation: 1,
|
||||
host_id: 'host',
|
||||
port: 28960
|
||||
} as never);
|
||||
vi.mocked(nativeRequest).mockResolvedValue({
|
||||
ok: true,
|
||||
text: 'map: mp_kino\nnum score ping guid name\n--- ----- ---- ---- ----\n'
|
||||
});
|
||||
expect(await rconStatusResult(server.name)).toMatchObject({
|
||||
ok: true,
|
||||
status: { players: 0, map: 'mp_kino' }
|
||||
});
|
||||
});
|
||||
|
||||
it('shares overlapping health and occupancy probes and probes again after completion', async () => {
|
||||
@@ -128,7 +171,7 @@ describe('RCON status transport', () => {
|
||||
expect(sockets).toHaveLength(3);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
expect(await request).toMatchObject({ ok: false, reason: 'timeout' });
|
||||
expect(sockets.every(socket => socket.close.mock.calls.length === 1)).toBe(true);
|
||||
expect(sockets.every((socket) => socket.close.mock.calls.length === 1)).toBe(true);
|
||||
|
||||
const fresh = rconStatusResult(server.name);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
@@ -137,16 +180,19 @@ describe('RCON status transport', () => {
|
||||
expect(await fresh).toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
it.each(['event', 'callback'])('retries transient send errors reported through the %s', async source => {
|
||||
const request = rconStatusResult(server.name);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
if (source === 'event') sockets[0].emit('error', new Error('UDP unavailable'));
|
||||
else sockets[0].send.mock.calls[0][5](new Error('UDP unavailable'));
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
expect(sockets).toHaveLength(2);
|
||||
reply(1);
|
||||
expect(await request).toMatchObject({ ok: true });
|
||||
});
|
||||
it.each(['event', 'callback'])(
|
||||
'retries transient send errors reported through the %s',
|
||||
async (source) => {
|
||||
const request = rconStatusResult(server.name);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
if (source === 'event') sockets[0].emit('error', new Error('UDP unavailable'));
|
||||
else sockets[0].send.mock.calls[0][5](new Error('UDP unavailable'));
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
expect(sockets).toHaveLength(2);
|
||||
reply(1);
|
||||
expect(await request).toMatchObject({ ok: true });
|
||||
}
|
||||
);
|
||||
|
||||
it.each([
|
||||
['missing prefix', Buffer.from('wrong listener')],
|
||||
@@ -162,7 +208,10 @@ describe('RCON status transport', () => {
|
||||
|
||||
it('does not retry missing configuration or endpoint failures', async () => {
|
||||
vi.mocked(rconConfigured).mockReturnValue(false);
|
||||
expect(await rconStatusResult(server.name)).toMatchObject({ ok: false, reason: 'no-config' });
|
||||
expect(await rconStatusResult(server.name)).toMatchObject({
|
||||
ok: false,
|
||||
reason: 'no-config'
|
||||
});
|
||||
server.game_port = null;
|
||||
expect(await rconStatusResult(server.name)).toMatchObject({ ok: false, reason: 'no-port' });
|
||||
vi.mocked(getServer).mockReturnValue(undefined);
|
||||
@@ -171,7 +220,9 @@ describe('RCON status transport', () => {
|
||||
});
|
||||
|
||||
it('cleans up a rejected in-flight probe so the next one can proceed', async () => {
|
||||
vi.mocked(createSocket).mockImplementationOnce(() => { throw new Error('Socket initialization failed'); });
|
||||
vi.mocked(createSocket).mockImplementationOnce(() => {
|
||||
throw new Error('Socket initialization failed');
|
||||
});
|
||||
await expect(rconStatusResult(server.name)).rejects.toThrow('Socket initialization failed');
|
||||
const request = rconStatusResult(server.name);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
@@ -179,22 +230,26 @@ describe('RCON status transport', () => {
|
||||
expect(await request).toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
it.each(['restart', 'registration', 'host', 'port', 'server'])('does not share probes across a changed %s', async change => {
|
||||
const original = rconStatusResult(server.name);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
if (change === 'restart') vi.mocked(latestGameSubmission).mockReturnValue('2026-10-02T00:00:00Z');
|
||||
if (change === 'registration') server.created_at = '2026-10-02T00:00:00Z';
|
||||
if (change === 'host') server.rcon_host = 'new-host';
|
||||
if (change === 'port') server.game_port = 28961;
|
||||
if (change === 'server') server.name = 'other-server';
|
||||
const current = rconStatusResult(server.name);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(sockets).toHaveLength(2);
|
||||
reply();
|
||||
sockets[1].emit('message', packet('statusResponse\n\\mapname\\mp_ascension\n'));
|
||||
expect(await original).toMatchObject({ ok: true, status: { players: 1 } });
|
||||
expect(await current).toMatchObject({ ok: true, status: { players: 0 } });
|
||||
});
|
||||
it.each(['restart', 'registration', 'host', 'port', 'server'])(
|
||||
'does not share probes across a changed %s',
|
||||
async (change) => {
|
||||
const original = rconStatusResult(server.name);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
if (change === 'restart')
|
||||
vi.mocked(latestGameSubmission).mockReturnValue('2026-10-02T00:00:00Z');
|
||||
if (change === 'registration') server.created_at = '2026-10-02T00:00:00Z';
|
||||
if (change === 'host') server.rcon_host = 'new-host';
|
||||
if (change === 'port') server.game_port = 28961;
|
||||
if (change === 'server') server.name = 'other-server';
|
||||
const current = rconStatusResult(server.name);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(sockets).toHaveLength(2);
|
||||
reply();
|
||||
sockets[1].emit('message', packet('statusResponse\n\\mapname\\mp_ascension\n'));
|
||||
expect(await original).toMatchObject({ ok: true, status: { players: 1 } });
|
||||
expect(await current).toMatchObject({ ok: true, status: { players: 0 } });
|
||||
}
|
||||
);
|
||||
|
||||
it('leaves other RCON commands on their existing single-attempt behavior', async () => {
|
||||
const request = rconPlutoniumVersion(server.name);
|
||||
|
||||
Reference in New Issue
Block a user