335 lines
14 KiB
TypeScript
335 lines
14 KiB
TypeScript
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<T>() {
|
|
let resolve!: (value: T) => void;
|
|
const promise = new Promise<T>((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<TailResult>();
|
|
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<TailResult>();
|
|
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);
|
|
});
|
|
});
|