Read chat logs incrementally and drain retention (Refs #62)
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdir, rm, utimes, writeFile } from 'node:fs/promises';
|
||||
import { appendFile, mkdir, rm, utimes, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||
import { gameEvent } from '../helpers/telemetry.js';
|
||||
@@ -53,6 +53,78 @@ async function get(server = SERVER) {
|
||||
}
|
||||
|
||||
describe('server chat API', () => {
|
||||
it('buffers partial UTF-8 lines and avoids context queries when the log is unchanged', async () => {
|
||||
await report('game_start', 0);
|
||||
await report('heartbeat', 40, 4);
|
||||
await log(['60:50 say;abc;0;Player;first'], 50);
|
||||
expect((await (await get()).json()).entries).toHaveLength(1);
|
||||
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);
|
||||
prepare.mockRestore();
|
||||
const path = join(fixture.t5, SERVER, 'logs/games_sp.log');
|
||||
const line = Buffer.from('61:00 say;abc;0;Player;café\n');
|
||||
await appendFile(path, line.subarray(0, -2)); // Split inside é, before the newline.
|
||||
await utimes(path, new Date(at(60)), new Date(at(60)));
|
||||
expect((await (await get()).json()).entries).toHaveLength(1);
|
||||
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[1].context.realTime).toBe(at(60));
|
||||
expect((await (await get()).json()).entries).toEqual(body.entries);
|
||||
});
|
||||
|
||||
it('retries appended bytes if archiving fails before publishing the cursor', async () => {
|
||||
await report('game_start', 0);
|
||||
await report('heartbeat', 40, 4);
|
||||
await log(['60:50 say;abc;0;Player;first'], 50);
|
||||
await get();
|
||||
const path = join(fixture.t5, SERVER, 'logs/games_sp.log');
|
||||
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 prepare = vi.spyOn(handle, 'prepare').mockImplementation((sql: string) => {
|
||||
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']);
|
||||
});
|
||||
|
||||
it('skips empty, stopped and offline servers and collects one last time after players leave', async () => {
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const { createHost } = await import('../../src/lib/server/hosts/index.js');
|
||||
const logs = await import('../../src/lib/server/fleet/logs.js');
|
||||
const read = vi.spyOn(logs, 'readLogChunk');
|
||||
const { collectFleetChat } = await import('../../src/lib/server/fleet/chat.js');
|
||||
db().prepare("UPDATE servers SET last_players=0,container_state='running'").run();
|
||||
await collectFleetChat();
|
||||
expect(read).not.toHaveBeenCalled();
|
||||
db().prepare('UPDATE servers SET last_players=1 WHERE name=?').run(SERVER);
|
||||
await log(['60:50 say;abc;0;Player;playing'], 50);
|
||||
await collectFleetChat();
|
||||
expect(read).toHaveBeenCalledTimes(1);
|
||||
db().prepare('UPDATE servers SET last_players=0 WHERE name=?').run(SERVER);
|
||||
await collectFleetChat();
|
||||
await collectFleetChat();
|
||||
expect(read).toHaveBeenCalledTimes(2);
|
||||
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));
|
||||
await collectFleetChat();
|
||||
expect(read).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('keeps real timestamps and historical match context stable across refreshes', async () => {
|
||||
await report('game_start', 0);
|
||||
await report('round_change', 40, 4);
|
||||
@@ -100,7 +172,7 @@ 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, 'tailLog').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);
|
||||
|
||||
Reference in New Issue
Block a user