Improve application responsiveness and background processing
This commit is contained in:
@@ -47,6 +47,8 @@ export async function newFixture(servers: string[] = ['t5_zombie_theater_1']): P
|
||||
dbPath,
|
||||
cleanup: async () => {
|
||||
try {
|
||||
const { closeReports } = await import('../../src/lib/server/reports/index.js');
|
||||
await closeReports();
|
||||
const mod = (await import('../../src/lib/server/db/index.js')) as {
|
||||
closeDb: () => void;
|
||||
};
|
||||
|
||||
@@ -5,7 +5,7 @@ import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getUploadFileProgress, hashUploadedFiles, MAX_UPLOAD_CHUNK_BYTES,
|
||||
receiveUploadChunk, UploadOffsetError, validateUploadPlan, withUploadLock
|
||||
receiveUploadChunk, UploadOffsetError, validateUploadPlan, withUploadLock, withUploadFileLock
|
||||
} from '../../src/lib/server/game-files/uploads.js';
|
||||
import { manifestChecksum, verifyGameFile } from '../../src/lib/server/game-files/storage.js';
|
||||
|
||||
@@ -22,6 +22,22 @@ beforeEach(async () => {
|
||||
});
|
||||
afterEach(async () => { await rm(directory, { recursive: true, force: true }); });
|
||||
|
||||
it('allows independent files together, serializes same-file offsets, and makes publication a barrier', async () => {
|
||||
let release!: () => void;
|
||||
const blocked = new Promise<void>(resolve => { release = resolve; });
|
||||
const order: string[] = [];
|
||||
const first = withUploadFileLock(root, 'a', async () => { order.push('a1'); await blocked; });
|
||||
const same = withUploadFileLock(root, 'a', async () => { order.push('a2'); });
|
||||
const other = withUploadFileLock(root, 'b', async () => { order.push('b'); });
|
||||
const publish = withUploadLock(root, async () => { order.push('publish'); });
|
||||
const later = withUploadFileLock(root, 'c', async () => { order.push('c'); });
|
||||
await other;
|
||||
expect(order).toEqual(['a1', 'b']);
|
||||
release();
|
||||
await Promise.all([first, same, publish, later]);
|
||||
expect(order).toEqual(['a1', 'b', 'a2', 'publish', 'c']);
|
||||
});
|
||||
|
||||
describe('browser folder upload plans', () => {
|
||||
it('preserves folder structure and source identity while sorting canonical paths', () => {
|
||||
expect(validateUploadPlan([{ path: 'zone/map.ff', size: 10, lastModified: 100 }, { path: 'main/data.iwd', size: 0 }])).toEqual([
|
||||
|
||||
@@ -89,20 +89,20 @@ describe('automatic host preparation', () => {
|
||||
});
|
||||
expect(operations(host.id)).toEqual([]);
|
||||
|
||||
const firstPoll = await pollOperations({ request: request('operations') }).json();
|
||||
const firstPoll = await (await pollOperations({ request: request('operations') })).json();
|
||||
expect(firstPoll.operations).toHaveLength(1);
|
||||
const runtimeOperation = firstPoll.operations[0];
|
||||
expect(runtimeOperation).toMatchObject({ kind: 'runtime.pull', payload: { image: runtime } });
|
||||
expect((await pollOperations({ request: request('operations') }).json()).operations).toEqual([runtimeOperation]);
|
||||
expect((await (await pollOperations({ request: request('operations') })).json()).operations).toEqual([runtimeOperation]);
|
||||
const version = await publish();
|
||||
expect((await pollOperations({ request: request('operations') }).json()).operations).toEqual([runtimeOperation]);
|
||||
expect((await (await pollOperations({ request: request('operations') })).json()).operations).toEqual([runtimeOperation]);
|
||||
|
||||
vi.setSystemTime(Date.now() + 1);
|
||||
const completedRuntime = await reportOperation({
|
||||
request: request(`operations/${runtimeOperation.id}`, { state: 'completed' }), params: { id: runtimeOperation.id }
|
||||
});
|
||||
expect(completedRuntime.status).toBe(200);
|
||||
expect((await pollOperations({ request: request('operations') }).json()).operations).toEqual([]);
|
||||
expect((await (await pollOperations({ request: request('operations') })).json()).operations).toEqual([]);
|
||||
expect(operations(host.id)).toHaveLength(1);
|
||||
|
||||
vi.setSystemTime(Date.now() + 1);
|
||||
@@ -113,7 +113,7 @@ describe('automatic host preparation', () => {
|
||||
const download = operations(host.id)[0];
|
||||
expect(download).toMatchObject({ kind: 'library.download', payload: { libraryVersionId: version.id } });
|
||||
expect((await reportHeartbeat({ request: request('heartbeat', { inventory: preparedInventory }) })).status).toBe(200);
|
||||
expect((await pollOperations({ request: request('operations') }).json()).operations).toEqual([download]);
|
||||
expect((await (await pollOperations({ request: request('operations') })).json()).operations).toEqual([download]);
|
||||
expect(operations(host.id)).toHaveLength(2);
|
||||
|
||||
vi.setSystemTime(Date.now() + 1);
|
||||
@@ -129,7 +129,7 @@ describe('automatic host preparation', () => {
|
||||
expect(finalHeartbeat.status).toBe(200);
|
||||
expect(libraries.requireReadyLibrary(host.id, version.id).id).toBe(version.id);
|
||||
expect(preparation.hostPreparationStatus(hosts.getHost(host.id)!).state).toBe('ready');
|
||||
expect((await pollOperations({ request: request('operations') }).json()).operations).toEqual([]);
|
||||
expect((await (await pollOperations({ request: request('operations') })).json()).operations).toEqual([]);
|
||||
expect(operations(host.id)).toHaveLength(2);
|
||||
});
|
||||
|
||||
|
||||
@@ -123,7 +123,7 @@ it('qualifies before competition ranking, then searches and paginates without ch
|
||||
expect(page).toMatchObject({ total: 3, entries: [{ guid: 'Blake', rank: 1 }], summary: full.summary });
|
||||
expect(getLeaderboard({ metric: 'averageKills', offset: 3 })).toMatchObject({ total: 3, entries: [], summary: full.summary });
|
||||
const { GET } = await import('../../src/routes/api/leaderboard/+server.js');
|
||||
const response = GET({ url: new URL('http://localhost/api/leaderboard?metric=averageKills&game=t5&q=current&limit=1') });
|
||||
const response = await GET({ url: new URL('http://localhost/api/leaderboard?metric=averageKills&game=t5&q=current&limit=1') });
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toMatchObject({ metric: 'averageKills', total: 1,
|
||||
entries: [{ guid: 'Casey', rank: 3, averageKills: 10, gamesPlayed: 5 }], summary: full.summary });
|
||||
@@ -135,7 +135,7 @@ it('accepts each efficiency metric through the API when the qualified group is e
|
||||
const { efficiencyMetrics } = await import('../../src/lib/leaderboard-efficiency.js');
|
||||
const { GET } = await import('../../src/routes/api/leaderboard/+server.js');
|
||||
for (const { key } of efficiencyMetrics) {
|
||||
const response = GET({ url: new URL(`http://localhost/api/leaderboard?metric=${key}`) });
|
||||
const response = await GET({ url: new URL(`http://localhost/api/leaderboard?metric=${key}`) });
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toMatchObject({ metric: key, entries: [], total: 0,
|
||||
summary: { players: 1, matches: 4, kills: 80 } });
|
||||
|
||||
@@ -386,15 +386,15 @@ describe('player profiles', () => {
|
||||
expect(getPlayerProfile('101', 9999)?.offset).toBe(25);
|
||||
for (const offset of [-1, NaN, 0.5]) expect(getPlayerProfile('101', offset)?.offset).toBe(0);
|
||||
const { load } = await import('../../src/routes/leaderboard/players/[guid]/+page.server.js');
|
||||
expect(load({ params: { guid: '101' }, url: new URL('http://localhost/leaderboard/players/101?offset=25') }).profile).toEqual(second);
|
||||
expect((await load({ params: { guid: '101' }, url: new URL('http://localhost/leaderboard/players/101?offset=25') })).profile).toEqual(second);
|
||||
});
|
||||
|
||||
it('loads canonical profile URLs, encodes GUIDs and returns 404 for missing players', async () => {
|
||||
await seedRecords();
|
||||
const { load } = await import('../../src/routes/leaderboard/players/[guid]/+page.server.js');
|
||||
const url = new URL('http://localhost/leaderboard/players/101');
|
||||
expect(load({ params: { guid: '101' }, url }).profile).toMatchObject({ name: 'Ace', stats: { kills: 770, gamesPlayed: 4 } });
|
||||
expect(() => load({ params: { guid: 'missing' }, url })).toThrow(expect.objectContaining({ status: 404 }));
|
||||
expect((await load({ params: { guid: '101' }, url })).profile).toMatchObject({ name: 'Ace', stats: { kills: 770, gamesPlayed: 4 } });
|
||||
await expect(load({ params: { guid: 'missing' }, url })).rejects.toThrow(expect.objectContaining({ status: 404 }));
|
||||
const { playerProfileHref } = await import('../../src/lib/leaderboard.js');
|
||||
expect(playerProfileHref('001/2?#')).toBe('/leaderboard/players/001%2F2%3F%23');
|
||||
});
|
||||
@@ -417,7 +417,7 @@ describe('leaderboard API', () => {
|
||||
const handle = await seedRecords();
|
||||
handle.prepare("UPDATE leaderboard_players SET name = 'JoshWright' WHERE guid = '101'").run();
|
||||
const { GET } = await import('../../src/routes/api/leaderboard/+server.js');
|
||||
const response = GET({ url: new URL('http://localhost/api/leaderboard?q=JoshWright&metric=kills') });
|
||||
const response = await GET({ url: new URL('http://localhost/api/leaderboard?q=JoshWright&metric=kills') });
|
||||
expect(response.status).toBe(200);
|
||||
const result = await response.json() as LeaderboardResponse;
|
||||
expect(result).toMatchObject({ total: 1, summary: { players: 4, matches: 5, kills: 1140 } });
|
||||
@@ -429,7 +429,7 @@ describe('leaderboard API', () => {
|
||||
it('returns the requested ranking and pagination', async () => {
|
||||
await seedRecords();
|
||||
const { GET } = await import('../../src/routes/api/leaderboard/+server.js');
|
||||
const response = GET({ url: new URL('http://localhost/api/leaderboard?game=t5&map=t5%3Atheater&metric=kills&limit=1&offset=1') });
|
||||
const response = await GET({ url: new URL('http://localhost/api/leaderboard?game=t5&map=t5%3Atheater&metric=kills&limit=1&offset=1') });
|
||||
expect(response.status).toBe(200);
|
||||
const result = await response.json() as LeaderboardResponse;
|
||||
expect(result).toMatchObject({ metric: 'kills', limit: 1, offset: 1, total: 3 });
|
||||
@@ -442,7 +442,7 @@ describe('leaderboard API', () => {
|
||||
for (const query of ['game=t7', 'game=', 'metric=unknown', 'limit=0', 'limit=101', 'limit=',
|
||||
'limit=1.5', 'limit=1e1', 'offset=-1', 'offset=1000001', 'offset=Infinity', 'offset=0.5',
|
||||
`q=${'a'.repeat(101)}`, `map=${'a'.repeat(1025)}`]) {
|
||||
const response = GET({ url: new URL(`http://localhost/api/leaderboard?${query}`) });
|
||||
const response = await GET({ url: new URL(`http://localhost/api/leaderboard?${query}`) });
|
||||
expect(response.status, query).toBe(400);
|
||||
expect(await response.json()).toMatchObject({ ok: false, kind: 'bad-request' });
|
||||
}
|
||||
@@ -455,9 +455,9 @@ describe('leaderboard API', () => {
|
||||
handle.prepare('UPDATE leaderboard_matches SET map = ?, map_key = ?, map_name = ? WHERE id = 1')
|
||||
.run(map, key, map);
|
||||
const { GET } = await import('../../src/routes/api/leaderboard/+server.js');
|
||||
const all = await GET({ url: new URL('http://localhost/api/leaderboard') }).json() as LeaderboardResponse;
|
||||
const all = await (await GET({ url: new URL('http://localhost/api/leaderboard') })).json() as LeaderboardResponse;
|
||||
const returnedKey = all.maps.find(option => option.map === map)!.key;
|
||||
const response = GET({ url: new URL(`http://localhost/api/leaderboard?map=${encodeURIComponent(returnedKey)}`) });
|
||||
const response = await GET({ url: new URL(`http://localhost/api/leaderboard?map=${encodeURIComponent(returnedKey)}`) });
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toMatchObject({ total: 2, summary: { matches: 1, kills: 200 } });
|
||||
});
|
||||
|
||||
@@ -150,16 +150,16 @@ describe('historical metrics', () => {
|
||||
vi.spyOn(Date, 'now').mockReturnValue(at + 30_000);
|
||||
for (const range of ['1', '7', '30', 'invalid', '365', '-1', '']) {
|
||||
const url = new URL(`http://localhost/metrics?days=${range}`);
|
||||
const response = GET({ url });
|
||||
const response = await GET({ url });
|
||||
const payload = await response.json();
|
||||
expect(response.headers.get('cache-control')).toBe('no-store');
|
||||
expect(payload).toEqual(await load({ url } as Parameters<typeof load>[0]));
|
||||
expect(payload.days).toBe(['1', '7', '30'].includes(range) ? Number(range) : 7);
|
||||
}
|
||||
expect((await GET({ url: new URL('http://localhost/api/metrics') }).json()).days).toBe(7);
|
||||
expect((await (await GET({ url: new URL('http://localhost/api/metrics') })).json()).days).toBe(7);
|
||||
for (const search of ['days=1&game=t5&map=Moon&timeZone=Asia%2FKathmandu', 'days=7&map=Moon', 'game=t6&timeZone=Invalid']) {
|
||||
const url = new URL(`http://localhost/metrics?${search}`);
|
||||
const payload = await GET({ url }).json();
|
||||
const payload = await (await GET({ url })).json();
|
||||
const { days, ...scope } = parseMetricsQuery(url.searchParams);
|
||||
expect(payload).toEqual(await load({ url } as Parameters<typeof load>[0]));
|
||||
expect(payload).toMatchObject({ days, scope });
|
||||
|
||||
@@ -15,6 +15,24 @@ async function enrolled() {
|
||||
}
|
||||
|
||||
describe('native host enrollment and scoped authentication', () => {
|
||||
it('separates observation and mutation delivery and wakes long polls on newly queued work', async () => {
|
||||
const api = await enrolled();
|
||||
const inspect = api.queueHostOperation(api.host.id, 'host.inspect', {});
|
||||
const restart = api.queueHostOperation(api.host.id, 'instance.restart', {});
|
||||
const status = api.queueHostOperation(api.host.id, 'rcon.execute', { command: 'status' });
|
||||
const command = api.queueHostOperation(api.host.id, 'rcon.execute', { command: 'map_restart' });
|
||||
expect(api.pendingHostOperations(api.host.id, 'read').map(op => op.id).sort()).toEqual([inspect.id, status.id].sort());
|
||||
expect(api.pendingHostOperations(api.host.id, 'write').map(op => op.id).sort()).toEqual([restart.id, command.id].sort());
|
||||
// Let earlier enqueue notifications finish before subscribing.
|
||||
await Promise.resolve();
|
||||
const controller = new AbortController();
|
||||
const waiting = api.waitForHostWork(api.host.id, controller.signal, 20_000);
|
||||
api.queueHostOperation(api.host.id, 'host.inspect', {});
|
||||
await waiting;
|
||||
const aborted = api.waitForHostWork(api.host.id, controller.signal, 20_000);
|
||||
controller.abort();
|
||||
await aborted;
|
||||
});
|
||||
it('consumes an enrollment exactly once and stores only credential hashes', async () => {
|
||||
const api = await enrolled();
|
||||
expect(api.authenticateCredential(api.credentials.credential)?.id).toBe(api.host.id);
|
||||
|
||||
@@ -74,15 +74,15 @@ it('keeps lifetime profiles available when selected history is empty or an effic
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
addMatches(db(), '101', 4);
|
||||
const { load } = await import('../../src/routes/leaderboard/players/[guid]/+page.server.js');
|
||||
const filtered = load({ params: { guid: '101' }, url: new URL('http://localhost/leaderboard/players/101?game=t6&map=t6%3Atown&offset=25') });
|
||||
const filtered = await load({ params: { guid: '101' }, url: new URL('http://localhost/leaderboard/players/101?game=t6&map=t6%3Atown&offset=25') });
|
||||
expect(filtered).toMatchObject({ returnTo: '/leaderboard',
|
||||
profile: { stats: { gamesPlayed: 4, kills: 40 }, matches: [], matchesTotal: 0, offset: 0 },
|
||||
scope: { game: 't6', map: 't6:town', metric: 'highestRound', entry: null } });
|
||||
const unqualified = load({ params: { guid: '101' }, url: new URL('http://localhost/leaderboard/players/101?metric=averageKills') });
|
||||
const unqualified = await load({ params: { guid: '101' }, url: new URL('http://localhost/leaderboard/players/101?metric=averageKills') });
|
||||
expect(unqualified).toMatchObject({ profile: { matchesTotal: 4, stats: { gamesPlayed: 4 } },
|
||||
scope: { game: '', map: '', metric: 'averageKills', entry: null } });
|
||||
addMatches(db(), '101', 1);
|
||||
expect(load({ params: { guid: '101' }, url: new URL('http://localhost/leaderboard/players/101?metric=averageKills') }))
|
||||
expect(await load({ params: { guid: '101' }, url: new URL('http://localhost/leaderboard/players/101?metric=averageKills') }))
|
||||
.toMatchObject({ scope: { entry: { guid: '101', rank: 1, averageKills: 10, gamesPlayed: 5 } } });
|
||||
});
|
||||
|
||||
@@ -93,13 +93,13 @@ it('loads canonical navigation context while keeping source search and paginatio
|
||||
const { load } = await import('../../src/routes/leaderboard/players/[guid]/+page.server.js');
|
||||
const url = new URL('http://localhost/leaderboard/players/101?game=t5&map=t5%3Atheater&metric=kills');
|
||||
url.searchParams.set('returnTo', '/leaderboard?game=t5&map=t5%3Atheater&metric=kills&q=101&offset=50&unknown=drop');
|
||||
const loaded = load({ params: { guid: '101' }, url });
|
||||
const loaded = await load({ params: { guid: '101' }, url });
|
||||
expect(loaded.returnTo).toBe('/leaderboard?game=t5&map=t5%3Atheater&metric=kills&q=101&offset=50');
|
||||
expect(loaded.scope).toMatchObject({ game: 't5', map: 't5:theater', metric: 'kills', entry: { guid: '101', rank: 2 } });
|
||||
expect(loaded.profile.offset).toBe(0);
|
||||
url.searchParams.set('returnTo', 'https://external.invalid/leaderboard');
|
||||
expect(load({ params: { guid: '101' }, url }).returnTo).toBe('/leaderboard');
|
||||
const defaults = load({ params: { guid: '101' }, url: new URL('http://localhost/leaderboard/players/101') });
|
||||
expect((await load({ params: { guid: '101' }, url })).returnTo).toBe('/leaderboard');
|
||||
const defaults = await load({ params: { guid: '101' }, url: new URL('http://localhost/leaderboard/players/101') });
|
||||
expect(defaults).toMatchObject({ returnTo: '/leaderboard', scope: { game: '', map: '', metric: 'highestRound' } });
|
||||
});
|
||||
|
||||
@@ -108,13 +108,11 @@ it('rejects invalid filter parameters and retains existing offset normalization
|
||||
addMatches(db(), '101', 30);
|
||||
const { load } = await import('../../src/routes/leaderboard/players/[guid]/+page.server.js');
|
||||
for (const query of ['game=t7', 'game=', 'metric=', 'metric=unknown', `map=${'x'.repeat(1025)}`]) {
|
||||
expect(() => load({ params: { guid: '101' }, url: new URL(`http://localhost/leaderboard/players/101?${query}`) }))
|
||||
.toThrow(expect.objectContaining({ status: 400 }));
|
||||
await expect(load({ params: { guid: '101' }, url: new URL(`http://localhost/leaderboard/players/101?${query}`) })).rejects.toThrow(expect.objectContaining({ status: 400 }));
|
||||
}
|
||||
for (const query of ['offset=garbage', 'offset=-1', 'offset=1.5', 'offset=Infinity']) {
|
||||
expect(load({ params: { guid: '101' }, url: new URL(`http://localhost/leaderboard/players/101?${query}`) }).profile.offset).toBe(0);
|
||||
expect((await load({ params: { guid: '101' }, url: new URL(`http://localhost/leaderboard/players/101?${query}`) })).profile.offset).toBe(0);
|
||||
}
|
||||
expect(load({ params: { guid: '101' }, url: new URL('http://localhost/leaderboard/players/101?offset=999') }).profile.offset).toBe(25);
|
||||
expect(() => load({ params: { guid: 'missing' }, url: new URL('http://localhost/leaderboard/players/missing?game=t5') }))
|
||||
.toThrow(expect.objectContaining({ status: 404 }));
|
||||
expect((await load({ params: { guid: '101' }, url: new URL('http://localhost/leaderboard/players/101?offset=999') })).profile.offset).toBe(25);
|
||||
await expect(load({ params: { guid: 'missing' }, url: new URL('http://localhost/leaderboard/players/missing?game=t5') })).rejects.toThrow(expect.objectContaining({ status: 404 }));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||
import { parseMetricsQuery } from '../../src/lib/metrics.js';
|
||||
|
||||
let fixture: Fixture;
|
||||
beforeEach(async () => { vi.resetModules(); fixture = await newFixture([]); });
|
||||
afterEach(async () => { vi.restoreAllMocks(); await fixture.cleanup(); });
|
||||
|
||||
it('runs reports off the request thread and invalidates cached history after writes', async () => {
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const { recordMetrics, readMetrics } = await import('../../src/lib/server/fleet/metrics.js');
|
||||
const { metricsReport } = await import('../../src/lib/server/reports/index.js');
|
||||
const now = Date.UTC(2026, 9, 4, 12);
|
||||
vi.spyOn(Date, 'now').mockReturnValue(now);
|
||||
const sample = { server: 'one', game: 't5', map: 'Moon', players: 1, capacity: 4 };
|
||||
recordMetrics([sample], now - 30_000);
|
||||
const query = parseMetricsQuery(new URLSearchParams('days=1'));
|
||||
const first = metricsReport(query);
|
||||
const winner = await Promise.race([first.then(() => 'report'), new Promise<string>(resolve => setTimeout(() => resolve('timer'), 0))]);
|
||||
expect(winner).toBe('timer');
|
||||
expect(await first).toEqual(readMetrics(query, now));
|
||||
expect(await metricsReport(query)).toEqual(await first);
|
||||
db().prepare('UPDATE metric_samples SET players=3').run();
|
||||
const changed = await metricsReport(query);
|
||||
expect(changed).toEqual(readMetrics(query, now));
|
||||
expect(changed.summary.playerHours).toBeCloseTo((await first).summary.playerHours * 3);
|
||||
});
|
||||
@@ -18,8 +18,13 @@ describe('server detail data', () => {
|
||||
for (let n = 0; n < 501; n++) { deployment.run('other-' + n, '2026-09-18'); member.run('other-' + n, 'other'); }
|
||||
})();
|
||||
const { GET } = await import('../../src/routes/api/campaigns/+server.js');
|
||||
const all = await (await GET({ url: new URL('http://localhost/api/campaigns') })).json();
|
||||
const all = await (await GET({ url: new URL('http://localhost/api/campaigns?limit=500') })).json();
|
||||
expect(all.deployments).toHaveLength(500);
|
||||
const first = await (await GET({ url: new URL('http://localhost/api/campaigns?limit=25') })).json();
|
||||
const second = await (await GET({ url: new URL('http://localhost/api/campaigns?limit=25&offset=25') })).json();
|
||||
expect(first.total).toBe(502);
|
||||
expect(first.deployments).toHaveLength(25);
|
||||
expect(new Set([...first.deployments, ...second.deployments].map(row => row.id)).size).toBe(50);
|
||||
const filtered = await (await GET({ url: new URL('http://localhost/api/campaigns?server=target') })).json();
|
||||
expect(filtered.deployments.map((row: { id: string }) => row.id)).toEqual(['older-target']);
|
||||
expect(filtered.deployments[0].servers).toHaveLength(2);
|
||||
|
||||
@@ -76,6 +76,7 @@ it('timestamps initial telemetry and subsequent status and telemetry events with
|
||||
};
|
||||
try {
|
||||
await reader.read(); // Initial SSE comment.
|
||||
expect(await nextEvent()).toMatchObject({ snapshot: true, serverNow: startedAt, entries: expect.any(Array) });
|
||||
expect(await nextEvent()).toMatchObject({ snapshot: true, entries: [], serverNow: startedAt });
|
||||
clock.mockReturnValue(startedAt + 30_000);
|
||||
emit({ type: 'server-status', entries: [] });
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { afterEach, expect, it, vi } from 'vitest';
|
||||
import { fetchCampaigns } from '../../src/lib/ui/campaigns.js';
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
it('loads history beyond the API page limit without truncating or fetching unwanted older rows', async () => {
|
||||
const requests: URL[] = [];
|
||||
vi.stubGlobal('fetch', vi.fn(async (input: string) => {
|
||||
const url = new URL(input, 'http://localhost');
|
||||
requests.push(url);
|
||||
const start = Number(url.searchParams.get('offset'));
|
||||
const count = Number(url.searchParams.get('limit'));
|
||||
return Response.json({ total: 900, deployments: Array.from({ length: count }, (_, i) => ({ id: start + i })) });
|
||||
}));
|
||||
const result = await fetchCampaigns<{ id: number }>({ server: 'test-server', view: 'history' }, 525);
|
||||
expect(result.total).toBe(900);
|
||||
expect(result.deployments.map(row => row.id)).toEqual(Array.from({ length: 525 }, (_, i) => i));
|
||||
expect(requests.map(url => Object.fromEntries(url.searchParams))).toEqual([
|
||||
{ server: 'test-server', view: 'history', offset: '0', limit: '500' },
|
||||
{ server: 'test-server', view: 'history', offset: '500', limit: '25' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('loads all active campaigns independently of the history limit', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(async (input: string) => {
|
||||
const url = new URL(input, 'http://localhost');
|
||||
const start = Number(url.searchParams.get('offset'));
|
||||
return Response.json({ total: 550, deployments: Array.from({ length: start ? 50 : 500 }, (_, i) => ({ id: start + i })) });
|
||||
}));
|
||||
const result = await fetchCampaigns<{ id: number }>({ active: 'true' }, Infinity);
|
||||
expect(result.deployments).toHaveLength(550);
|
||||
expect(result.deployments[549].id).toBe(549);
|
||||
});
|
||||
@@ -43,6 +43,8 @@ describe('visible page polling', () => {
|
||||
stop = pollVisible(task, 1000, false);
|
||||
expect(task).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
expect(task).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(task).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,6 +67,34 @@ async function setup() {
|
||||
}
|
||||
|
||||
describe('Fleet report clock', () => {
|
||||
it('accepts newer navigation snapshots while keeping stream evidence newer than the snapshot', async () => {
|
||||
const { fleetServers, hydrateFleet } = await setup();
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
emit(epoch + 2000, [status(epoch + 2000, { players: 3 })]);
|
||||
const integrations = { rcon: true, komodo: false, rconTimeoutMs: 3000, defaultRconHost: '' };
|
||||
hydrateFleet({ serverNow: epoch + 1000, servers: [server(epoch + 1000), server(epoch, { name: 'two' })], operations: [], integrations });
|
||||
expect(get(fleetServers)).toHaveLength(2);
|
||||
expect(get(fleetServers)[0].last_players).toBe(3);
|
||||
hydrateFleet({ serverNow: epoch, servers: [], operations: [], integrations });
|
||||
expect(get(fleetServers)).toHaveLength(2);
|
||||
});
|
||||
it('merges partial events without dropping other servers or repainting unchanged elapsed ticks', async () => {
|
||||
snapshot.servers = [server(), server(epoch, { name: 'two' })];
|
||||
const { fleetServers, statusByServer } = await setup();
|
||||
emit(epoch, [status(epoch), status(epoch, { server: 'two' })]);
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
source.dispatchEvent(new MessageEvent('server-status', { data: JSON.stringify({
|
||||
delta: true, serverNow: epoch + 1000, entries: [status(epoch + 1000, { players: 2 })]
|
||||
}) }));
|
||||
expect(get(statusByServer).size).toBe(2);
|
||||
expect(get(fleetServers).find(server => server.name === 't5_zombie_theater_1')?.last_players).toBe(2);
|
||||
const changes = vi.fn();
|
||||
const unsubscribe = fleetServers.subscribe(changes);
|
||||
changes.mockClear();
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
expect(changes).not.toHaveBeenCalled();
|
||||
unsubscribe();
|
||||
});
|
||||
it('keeps persisted occupancy and match anchors through live reports before the next status poll', async () => {
|
||||
const at = (seconds: number) => new Date(epoch + seconds * 1000).toISOString();
|
||||
snapshot.servers = [server(epoch, { container_state: 'running', container_checked_at: at(0) })];
|
||||
|
||||
Reference in New Issue
Block a user