import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { newFixture, type Fixture } from '../helpers/fixture.js'; import type { ContextualChatEntry } from '../../src/lib/game-chat.js'; import type { LogChunk, TailResult } from '../../src/lib/server/fleet/logs.js'; const SERVER = 't5_zombie_theater_1'; const OTHER_SERVER = 't6_zombie_town_1'; let fixture: Fixture; let baseline: number; beforeEach(async () => { vi.resetModules(); vi.useFakeTimers({ toFake: ['Date'] }); baseline = Date.now() - 600_000; fixture = await newFixture([SERVER, OTHER_SERVER]); const { seedServerRegistry } = await import('../../src/lib/server/fleet/servers.js'); const { db } = await import('../../src/lib/server/db/index.js'); await seedServerRegistry(); db() .prepare("UPDATE servers SET created_at=?, last_players=0, container_state='running'") .run(new Date(baseline - 120_000).toISOString()); db().prepare("UPDATE servers SET container_state='stopped' WHERE name=?").run(OTHER_SERVER); }); afterEach(async () => { vi.restoreAllMocks(); vi.useRealTimers(); await fixture.cleanup(); }); function tail(message: string): TailResult { const line = `1:00 say;abc;0;Player;${message}`; return { lines: [line], size: Buffer.byteLength(line) + 1, mtime: new Date(baseline + 60_000).toISOString(), truncated: false }; } function messages(body: { entries: ContextualChatEntry[] }): string[] { return body.entries.flatMap((entry) => entry.type === 'message' ? [entry.message.message] : [] ); } function chunk(content: string, offset: number, size: number, reset = false): LogChunk { const mtime = new Date(baseline + 60_000).toISOString(); return { contentBase64: Buffer.from(content).toString('base64'), size, mtime, truncated: false, reset, cursor: { fileId: 'chat-log', offset, size, modified: mtime, anchorLength: 0, anchorHash: '' } }; } function advance(milliseconds: number) { vi.setSystemTime(Date.now() + milliseconds); } function deferred() { let resolve!: (value: T) => void; const promise = new Promise((done) => { resolve = done; }); return { promise, resolve }; } async function setup() { const logs = await import('../../src/lib/server/fleet/logs.js'); const read = vi.spyOn(logs, 'readLogChunk').mockResolvedValue(tail('first')); const { GET } = await import('../../src/routes/api/servers/[name]/chat/+server.js'); const { db } = await import('../../src/lib/server/db/index.js'); const { collectFleetChat } = await import('../../src/lib/server/fleet/chat.js'); return { read, db: db(), collectFleetChat, get: (server = SERVER) => GET({ params: { name: server } }) }; } async function addInstance() { const { createHost } = await import('../../src/lib/server/hosts/index.js'); const { db } = await import('../../src/lib/server/db/index.js'); const host = createHost({ name: 'Chat host' }); db() .prepare( `INSERT INTO native_instances(id,server,host_id,library_id,image,port,created_at) VALUES('chat-instance',?,?,'library','image',28960,?)` ) .run(SERVER, host.id, new Date(baseline).toISOString()); } describe('server chat refresh sharing', () => { it('picks up surviving logs once when collection starts on an idle server', async () => { const s = await setup(); await s.collectFleetChat(); expect(messages(await (await s.get()).json())).toEqual(['first']); advance(60_000); await s.collectFleetChat(); expect(s.read).toHaveBeenCalledTimes(1); }); it('drains all remaining chunks after players leave before anchoring messages', async () => { const s = await setup(); const lines = [ '0:30 say;abc;0;Player;first\n', '0:40 say;abc;0;Player;middle\n', '1:00 say;abc;0;Player;last\n' ]; const sizes = lines.map((line) => Buffer.byteLength(line)); const total = sizes.reduce((a, b) => a + b, 0); s.read .mockResolvedValueOnce(chunk(lines[0], sizes[0], total, true)) .mockResolvedValueOnce(chunk(lines[1], sizes[0] + sizes[1], total)) .mockResolvedValueOnce(chunk(lines[2], total, total)); s.db.prepare('UPDATE servers SET last_players=1 WHERE name=?').run(SERVER); await s.collectFleetChat(); expect(messages(await (await s.get()).json())).toEqual([]); s.db.prepare('UPDATE servers SET last_players=0 WHERE name=?').run(SERVER); await s.collectFleetChat(); expect(messages(await (await s.get()).json())).toEqual([]); await s.collectFleetChat(); expect(messages(await (await s.get()).json())).toEqual(['first', 'middle', 'last']); await s.collectFleetChat(); expect(s.read).toHaveBeenCalledTimes(3); expect(s.read.mock.calls.map((call) => call[1]?.offset)).toEqual([ undefined, sizes[0], sizes[0] + sizes[1] ]); }); it('bounds stalled partial-line retries and resumes collection when players return', async () => { const s = await setup(); const partial = '1:00 say;abc;0;Player;incomplete'; const size = Buffer.byteLength(partial); s.read .mockResolvedValueOnce(chunk(partial, size, size, true)) .mockResolvedValue(chunk('', size, size)); await s.collectFleetChat(); await s.collectFleetChat(); expect(s.read).toHaveBeenCalledTimes(2); await s.collectFleetChat(); expect(s.read).toHaveBeenCalledTimes(2); for (let attempt = 0; attempt < 5; attempt++) { advance(15_000); await s.collectFleetChat(); } expect(s.read).toHaveBeenCalledTimes(4); s.db.prepare('UPDATE servers SET last_players=1 WHERE name=?').run(SERVER); s.read.mockResolvedValue(chunk('\n', size + 1, size + 1)); await s.collectFleetChat(); expect(messages(await (await s.get()).json())).toEqual(['incomplete']); expect(s.read).toHaveBeenCalledTimes(5); }); it('stops idle reads for missing logs and bounds retries for read failures', async () => { const s = await setup(); s.read.mockResolvedValueOnce(null); await s.collectFleetChat(); advance(60_000); await s.collectFleetChat(); expect(s.read).toHaveBeenCalledTimes(1); // A new registration gets its own initial attempt and retry budget. s.db .prepare('UPDATE servers SET created_at=? WHERE name=?') .run(new Date(baseline - 60_000).toISOString(), SERVER); s.read.mockRejectedValue(new Error('Unavailable source')); vi.spyOn(console, 'error').mockImplementation(() => {}); await s.collectFleetChat(); await s.collectFleetChat(); expect(s.read).toHaveBeenCalledTimes(2); for (let attempt = 0; attempt < 5; attempt++) { advance(15_000); await s.collectFleetChat(); } expect(s.read).toHaveBeenCalledTimes(4); }); it('serves worker state to repeated viewers and still collects after players leave', async () => { const s = await setup(); s.db.prepare('UPDATE servers SET last_players=1 WHERE name=?').run(SERVER); await s.collectFleetChat(); expect(s.read).toHaveBeenCalledTimes(1); expect(messages(await (await s.get()).json())).toEqual(['first']); const responses = await Promise.all(Array.from({ length: 5 }, () => s.get())); for (const response of responses) expect(messages(await response.json())).toEqual(['first']); expect(s.read).toHaveBeenCalledTimes(1); s.db.prepare('UPDATE servers SET last_players=0 WHERE name=?').run(SERVER); s.read.mockResolvedValue(tail('last message')); await s.collectFleetChat(); await s.collectFleetChat(); expect(s.read).toHaveBeenCalledTimes(2); expect(messages(await (await s.get()).json())).toEqual(['last message']); expect(s.read).toHaveBeenCalledTimes(2); }); it('refreshes exactly when the completed read becomes fifteen seconds old', async () => { const s = await setup(); expect(messages(await (await s.get()).json())).toEqual(['first']); s.read.mockResolvedValue(tail('second message')); advance(14_999); expect(messages(await (await s.get()).json())).toEqual(['first']); expect(s.read).toHaveBeenCalledTimes(1); advance(1); const responses = await Promise.all([s.get(), s.get(), s.get()]); for (const response of responses) expect(messages(await response.json())).toEqual(['second message']); expect(s.read).toHaveBeenCalledTimes(2); }); it('shares slow worker reads and starts the cooldown when they complete', async () => { const s = await setup(); const slow = deferred(); s.read.mockImplementationOnce(() => slow.promise); s.db.prepare('UPDATE servers SET last_players=1 WHERE name=?').run(SERVER); const collection = s.collectFleetChat(); const first = s.get(); advance(60_000); const second = s.get(); expect(s.read).toHaveBeenCalledTimes(1); slow.resolve(tail('slow message')); await collection; for (const response of await Promise.all([first, second])) expect(messages(await response.json())).toEqual(['slow message']); advance(14_999); expect(messages(await (await s.get()).json())).toEqual(['slow message']); expect(s.read).toHaveBeenCalledTimes(1); advance(1); await s.get(); expect(s.read).toHaveBeenCalledTimes(2); }); it('caches missing logs until the next allowed attempt', async () => { const s = await setup(); s.read.mockResolvedValueOnce(null); const missing = { ok: true, entries: [], missing: true }; expect(await (await s.get()).json()).toEqual(missing); advance(14_999); for (const response of await Promise.all([s.get(), s.get()])) expect(await response.json()).toEqual(missing); expect(s.read).toHaveBeenCalledTimes(1); advance(1); expect(messages(await (await s.get()).json())).toEqual(['first']); expect(s.read).toHaveBeenCalledTimes(2); }); it('backs off failed reads and retries without leaking the original error', async () => { const s = await setup(); s.read.mockRejectedValueOnce(new Error('read failed at /private/server/log')); vi.spyOn(console, 'error').mockImplementation(() => {}); const first = await s.get(); expect(first.status).toBe(500); expect(await first.json()).toEqual({ ok: false, reason: 'Could not read server chat' }); advance(14_999); for (const response of await Promise.all([s.get(), s.get()])) { expect(response.status).toBe(500); expect(await response.json()).toEqual({ ok: false, reason: 'Could not read server chat' }); } expect(s.read).toHaveBeenCalledTimes(1); advance(1); expect(messages(await (await s.get()).json())).toEqual(['first']); expect(s.read).toHaveBeenCalledTimes(2); }); it('keeps each server and every replacement identity on its own cooldown', async () => { const s = await setup(); await addInstance(); expect(messages(await (await s.get()).json())).toEqual(['first']); s.read.mockResolvedValue(tail('other server')); expect(messages(await (await s.get(OTHER_SERVER)).json())).toEqual(['other server']); expect(messages(await (await s.get()).json())).toEqual(['first']); expect(s.read).toHaveBeenCalledTimes(2); s.db .prepare('UPDATE native_instances SET generation=generation+1 WHERE server=?') .run(SERVER); s.read.mockResolvedValue(tail('new generation')); expect(messages(await (await s.get()).json())).toEqual(['new generation']); s.db .prepare("UPDATE native_instances SET id='replacement-instance' WHERE server=?") .run(SERVER); s.read.mockResolvedValue(tail('new instance')); expect(messages(await (await s.get()).json())).toEqual(['new instance']); s.db .prepare('UPDATE servers SET created_at=? WHERE name=?') .run(new Date(baseline - 60_000).toISOString(), SERVER); s.read.mockResolvedValue(tail('new registration')); expect(messages(await (await s.get()).json())).toEqual(['new registration']); expect(messages(await (await s.get(OTHER_SERVER)).json())).toEqual(['other server']); expect(s.read).toHaveBeenCalledTimes(5); }); it('does not let an old in-flight generation overwrite its replacement cache', async () => { const s = await setup(); await addInstance(); const oldRead = deferred(); s.read.mockImplementationOnce(() => oldRead.promise); const oldResponse = s.get(); expect(s.read).toHaveBeenCalledTimes(1); s.db .prepare('UPDATE native_instances SET generation=generation+1 WHERE server=?') .run(SERVER); s.read.mockResolvedValue(tail('replacement')); expect(messages(await (await s.get()).json())).toEqual(['replacement']); advance(10_000); oldRead.resolve(tail('old generation')); expect(await (await oldResponse).json()).toEqual({ ok: true, entries: [], missing: true }); expect(messages(await (await s.get()).json())).toEqual(['replacement']); expect(s.read).toHaveBeenCalledTimes(2); advance(5_000); s.read.mockResolvedValue(tail('replacement refreshed')); expect(messages(await (await s.get()).json())).toEqual(['replacement refreshed']); expect(s.read).toHaveBeenCalledTimes(3); }); });