Add Prettier and ESLint tooling (Refs #77)
This commit is contained in:
+126
-33
@@ -4,6 +4,9 @@ import { join } from 'node:path';
|
||||
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||
import { gameEvent } from '../helpers/telemetry.js';
|
||||
import type { GameEvent } from '../../src/lib/telemetry.js';
|
||||
import type { ContextualChatEntry } from '../../src/lib/game-chat.js';
|
||||
|
||||
type ChatMessageEntry = Extract<ContextualChatEntry, { type: 'message' }>;
|
||||
|
||||
const SERVER = 't5_zombie_theater_1';
|
||||
const T6_SERVER = 't6_zombie_town_1';
|
||||
@@ -29,21 +32,36 @@ afterEach(async () => {
|
||||
async function log(lines: string[], lastWrite = 60, server = SERVER, filename?: string) {
|
||||
const folder = join(server.startsWith('t6_') ? fixture.t6 : fixture.t5, server, 'logs');
|
||||
await mkdir(folder, { recursive: true });
|
||||
const path = join(folder, filename ?? (server.startsWith('t6_') ? 'games_zm.log' : 'games_sp.log'));
|
||||
const path = join(
|
||||
folder,
|
||||
filename ?? (server.startsWith('t6_') ? 'games_zm.log' : 'games_sp.log')
|
||||
);
|
||||
await writeFile(path, lines.join('\n') + '\n');
|
||||
await utimes(path, new Date(at(lastWrite)), new Date(at(lastWrite)));
|
||||
}
|
||||
|
||||
async function report(event: GameEvent['event'], seconds: number, round = 1, status = 200, diagnostic = true) {
|
||||
async function report(
|
||||
event: GameEvent['event'],
|
||||
seconds: number,
|
||||
round = 1,
|
||||
status = 200,
|
||||
diagnostic = true
|
||||
) {
|
||||
const value = gameEvent(event, round, 1, SERVER);
|
||||
if (status === 200) {
|
||||
const { saveTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
|
||||
saveTelemetry({ ...value, receivedAt: at(seconds) });
|
||||
}
|
||||
if (diagnostic) {
|
||||
const { recordTelemetryRequest } = await import('../../src/lib/server/fleet/telemetry-requests.js');
|
||||
recordTelemetryRequest({ value, receivedAt: at(seconds), status,
|
||||
error: status === 200 ? null : 'Rejected request', contentType: 'application/json' });
|
||||
const { recordTelemetryRequest } =
|
||||
await import('../../src/lib/server/fleet/telemetry-requests.js');
|
||||
recordTelemetryRequest({
|
||||
value,
|
||||
receivedAt: at(seconds),
|
||||
status,
|
||||
error: status === 200 ? null : 'Rejected request',
|
||||
contentType: 'application/json'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,8 +79,12 @@ describe('server chat API', () => {
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const prepare = vi.spyOn(db(), 'prepare');
|
||||
await get();
|
||||
expect(prepare.mock.calls.some(([sql]) => sql.includes('FROM telemetry_requests'))).toBe(false);
|
||||
expect(prepare.mock.calls.some(([sql]) => sql.includes('last_received_at AS lastReceivedAt'))).toBe(false);
|
||||
expect(prepare.mock.calls.some(([sql]) => sql.includes('FROM telemetry_requests'))).toBe(
|
||||
false
|
||||
);
|
||||
expect(
|
||||
prepare.mock.calls.some(([sql]) => sql.includes('last_received_at AS lastReceivedAt'))
|
||||
).toBe(false);
|
||||
prepare.mockRestore();
|
||||
const path = join(fixture.t5, SERVER, 'logs/games_sp.log');
|
||||
const line = Buffer.from('61:00 say;abc;0;Player;café\n');
|
||||
@@ -72,7 +94,10 @@ describe('server chat API', () => {
|
||||
await appendFile(path, line.subarray(-2));
|
||||
await utimes(path, new Date(at(60)), new Date(at(60)));
|
||||
const body = await (await get()).json();
|
||||
expect(body.entries.map((entry: any) => entry.message.message)).toEqual(['first', 'café']);
|
||||
expect(body.entries.map((entry: ChatMessageEntry) => entry.message.message)).toEqual([
|
||||
'first',
|
||||
'café'
|
||||
]);
|
||||
expect(body.entries[1].context.realTime).toBe(at(60));
|
||||
expect((await (await get()).json()).entries).toEqual(body.entries);
|
||||
});
|
||||
@@ -86,15 +111,21 @@ describe('server chat API', () => {
|
||||
await appendFile(path, '61:00 say;abc;0;Player;retry me\n');
|
||||
await utimes(path, new Date(at(60)), new Date(at(60)));
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const handle = db(), original = handle.prepare.bind(handle);
|
||||
const handle = db(),
|
||||
original = handle.prepare.bind(handle);
|
||||
const prepare = vi.spyOn(handle, 'prepare').mockImplementation((sql: string) => {
|
||||
if (sql.includes('INSERT INTO match_chat')) throw new Error('Temporary database failure');
|
||||
if (sql.includes('INSERT INTO match_chat'))
|
||||
throw new Error('Temporary database failure');
|
||||
return original(sql);
|
||||
});
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
expect((await get()).status).toBe(500);
|
||||
prepare.mockRestore();
|
||||
expect((await (await get()).json()).entries.map((entry: any) => entry.message.message)).toEqual(['first', 'retry me']);
|
||||
expect(
|
||||
(await (await get()).json()).entries.map(
|
||||
(entry: ChatMessageEntry) => entry.message.message
|
||||
)
|
||||
).toEqual(['first', 'retry me']);
|
||||
});
|
||||
|
||||
it('skips empty, stopped and offline servers and collects one last time after players leave', async () => {
|
||||
@@ -114,13 +145,19 @@ describe('server chat API', () => {
|
||||
await collectFleetChat();
|
||||
await collectFleetChat();
|
||||
expect(read).toHaveBeenCalledTimes(2);
|
||||
db().prepare("UPDATE servers SET last_players=1,container_state='stopped' WHERE name=?").run(SERVER);
|
||||
db()
|
||||
.prepare("UPDATE servers SET last_players=1,container_state='stopped' WHERE name=?")
|
||||
.run(SERVER);
|
||||
await collectFleetChat();
|
||||
expect(read).toHaveBeenCalledTimes(2);
|
||||
const host = createHost({ name: 'Offline' });
|
||||
db().prepare("UPDATE servers SET container_state='running' WHERE name=?").run(SERVER);
|
||||
db().prepare(`INSERT INTO native_instances(id,server,host_id,library_id,image,port,created_at)
|
||||
VALUES('offline-instance',?,?,'library','image',28960,?)`).run(SERVER, host.id, at(0));
|
||||
db()
|
||||
.prepare(
|
||||
`INSERT INTO native_instances(id,server,host_id,library_id,image,port,created_at)
|
||||
VALUES('offline-instance',?,?,'library','image',28960,?)`
|
||||
)
|
||||
.run(SERVER, host.id, at(0));
|
||||
await collectFleetChat();
|
||||
expect(read).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
@@ -134,10 +171,17 @@ describe('server chat API', () => {
|
||||
const body = await response.json();
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get('cache-control')).toBe('no-store');
|
||||
expect(body).toMatchObject({ ok: true, missing: false, entries: [
|
||||
{ type: 'message', message: { player: 'Player', message: 'hello' },
|
||||
context: { realTime: at(50), matchElapsedMs: 50_000, round: 4 } }
|
||||
] });
|
||||
expect(body).toMatchObject({
|
||||
ok: true,
|
||||
missing: false,
|
||||
entries: [
|
||||
{
|
||||
type: 'message',
|
||||
message: { player: 'Player', message: 'hello' },
|
||||
context: { realTime: at(50), matchElapsedMs: 50_000, round: 4 }
|
||||
}
|
||||
]
|
||||
});
|
||||
expect(JSON.stringify(body)).not.toContain('headshots');
|
||||
vi.spyOn(Date, 'now').mockReturnValue(baseline + 900_000);
|
||||
expect(await (await get()).json()).toEqual(body);
|
||||
@@ -172,7 +216,9 @@ describe('server chat API', () => {
|
||||
|
||||
it('reports read failures without returning server filesystem paths', async () => {
|
||||
const logs = await import('../../src/lib/server/fleet/logs.js');
|
||||
vi.spyOn(logs, 'readLogChunk').mockRejectedValue(new Error('read failed at /private/server/log'));
|
||||
vi.spyOn(logs, 'readLogChunk').mockRejectedValue(
|
||||
new Error('read failed at /private/server/log')
|
||||
);
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
const response = await get();
|
||||
expect(response.status).toBe(500);
|
||||
@@ -185,7 +231,11 @@ describe('server chat API', () => {
|
||||
await log(['60:50 say;abc;0;Player;first round', '61:00 J;abc;0;Player']);
|
||||
const body = await (await get()).json();
|
||||
expect(body.entries).toHaveLength(1);
|
||||
expect(body.entries[0].context).toEqual({ realTime: at(50), matchElapsedMs: 50_000, round: 1 });
|
||||
expect(body.entries[0].context).toEqual({
|
||||
realTime: at(50),
|
||||
matchElapsedMs: 50_000,
|
||||
round: 1
|
||||
});
|
||||
});
|
||||
|
||||
it('does not reuse round diagnostics after their retention window', async () => {
|
||||
@@ -194,7 +244,11 @@ describe('server chat API', () => {
|
||||
await log(['60:50 say;abc;0;Player;older message', '61:00 J;abc;0;Player']);
|
||||
vi.spyOn(Date, 'now').mockReturnValue(baseline + 8 * 86_400_000);
|
||||
const body = await (await get()).json();
|
||||
expect(body.entries[0].context).toEqual({ realTime: at(50), matchElapsedMs: 50_000, round: null });
|
||||
expect(body.entries[0].context).toEqual({
|
||||
realTime: at(50),
|
||||
matchElapsedMs: 50_000,
|
||||
round: null
|
||||
});
|
||||
});
|
||||
|
||||
it('does not associate retained messages with a deleted server incarnation', async () => {
|
||||
@@ -202,16 +256,27 @@ describe('server chat API', () => {
|
||||
await report('heartbeat', 40, 8);
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
db().prepare('DELETE FROM servers WHERE name = ?').run(SERVER);
|
||||
db().prepare(`INSERT INTO servers (name, game, map, created_at)
|
||||
VALUES (?, 't5', 'zombie_theater', ?)`).run(SERVER, at(70));
|
||||
db()
|
||||
.prepare(
|
||||
`INSERT INTO servers (name, game, map, created_at)
|
||||
VALUES (?, 't5', 'zombie_theater', ?)`
|
||||
)
|
||||
.run(SERVER, at(70));
|
||||
await report('game_start', 80);
|
||||
await report('heartbeat', 90, 2);
|
||||
await log(['60:50 say;abc;0;Player;previous server', '61:40 say;abc;0;Player;current server'], 100);
|
||||
await log(
|
||||
['60:50 say;abc;0;Player;previous server', '61:40 say;abc;0;Player;current server'],
|
||||
100
|
||||
);
|
||||
const body = await (await get()).json();
|
||||
const messages = body.entries.filter((entry: { type: string }) => entry.type === 'message');
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0].message.message).toBe('current server');
|
||||
expect(messages[0].context).toEqual({ realTime: at(100), matchElapsedMs: 20_000, round: 2 });
|
||||
expect(messages[0].context).toEqual({
|
||||
realTime: at(100),
|
||||
matchElapsedMs: 20_000,
|
||||
round: 2
|
||||
});
|
||||
});
|
||||
|
||||
it('clears at a new match before it writes to the log and keeps the old chat with its match', async () => {
|
||||
@@ -225,13 +290,24 @@ describe('server chat API', () => {
|
||||
await report('heartbeat', 90, 2);
|
||||
await log(['60:30 say;abc;0;Player;old match', '61:40 say;abc;0;Player;new match'], 100);
|
||||
const body = await (await get()).json();
|
||||
expect(body.entries.map((entry: any) => entry.message.message)).toEqual(['new match']);
|
||||
expect(body.entries.map((entry: ChatMessageEntry) => entry.message.message)).toEqual([
|
||||
'new match'
|
||||
]);
|
||||
const { getMatchDetail } = await import('../../src/lib/server/fleet/match-history.js');
|
||||
expect(getMatchDetail(1)!.chat.map(entry => entry.type === 'message' && entry.message.message)).toEqual(['old match']);
|
||||
expect(
|
||||
getMatchDetail(1)!.chat.map(
|
||||
(entry) => entry.type === 'message' && entry.message.message
|
||||
)
|
||||
).toEqual(['old match']);
|
||||
});
|
||||
|
||||
it('clears on log boundaries and clock resets before the next telemetry report', async () => {
|
||||
for (const boundary of ['61:00 InitGame:', '0:00 InitGame:', '0:00 J;abc;0;Player', 'InitGame:']) {
|
||||
for (const boundary of [
|
||||
'61:00 InitGame:',
|
||||
'0:00 InitGame:',
|
||||
'0:00 J;abc;0;Player',
|
||||
'InitGame:'
|
||||
]) {
|
||||
await report('game_start', 0);
|
||||
await report('heartbeat', 20, 4);
|
||||
await log(['60:30 say;abc;0;Player;old match'], 30);
|
||||
@@ -242,12 +318,26 @@ describe('server chat API', () => {
|
||||
});
|
||||
|
||||
it('shows only the latest log session without telemetry', async () => {
|
||||
await log(['80:00 say;abc;0;Player;old match', '0:00 InitGame:', '0:30 say;abc;0;Player;new match'], 60);
|
||||
await log(
|
||||
[
|
||||
'80:00 say;abc;0;Player;old match',
|
||||
'0:00 InitGame:',
|
||||
'0:30 say;abc;0;Player;new match'
|
||||
],
|
||||
60
|
||||
);
|
||||
const entries = (await (await get()).json()).entries;
|
||||
expect(entries.filter((entry: any) => entry.type === 'message').map((entry: any) => entry.message.message))
|
||||
.toEqual(['new match']);
|
||||
expect(
|
||||
entries
|
||||
.filter((entry: ContextualChatEntry) => entry.type === 'message')
|
||||
.map((entry: ChatMessageEntry) => entry.message.message)
|
||||
).toEqual(['new match']);
|
||||
await log(['80:00 say;abc;0;Player;old match', '0:00 InitGame:'], 60);
|
||||
expect((await (await get()).json()).entries.some((entry: any) => entry.type === 'message')).toBe(false);
|
||||
expect(
|
||||
(await (await get()).json()).entries.some(
|
||||
(entry: ContextualChatEntry) => entry.type === 'message'
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('collects without a viewer and retains repeated messages after log rotation, restart and server deletion', async () => {
|
||||
@@ -265,7 +355,10 @@ describe('server chat API', () => {
|
||||
const { getMatchDetail } = await import('../../src/lib/server/fleet/match-history.js');
|
||||
const saved = getMatchDetail(1)!.chat;
|
||||
expect(saved).toHaveLength(2);
|
||||
expect(saved[0]).toMatchObject({ message: { message: 'hello' }, context: { realTime: at(30), round: 4 } });
|
||||
expect(saved[0]).toMatchObject({
|
||||
message: { message: 'hello' },
|
||||
context: { realTime: at(30), round: 4 }
|
||||
});
|
||||
await rm(join(fixture.t5, SERVER, 'logs'), { recursive: true });
|
||||
closeDb();
|
||||
expect((await (await get()).json()).entries).toEqual(saved);
|
||||
|
||||
Reference in New Issue
Block a user