Simplify telemetry to three game events
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
import type { GameEvent, TelemetryPlayerStats } from '../../src/lib/telemetry.js';
|
||||
|
||||
export const roster = (count = 4): TelemetryPlayerStats[] => Array.from({ length: count }, (_, i) => ({
|
||||
name: `Player ${i + 1}`, guid: `1234567890123456${i}`, kills: 100 + i,
|
||||
headshots: 50, downs: 1, revives: 2, score: 5000
|
||||
}));
|
||||
export function gameEvent(event: GameEvent['event'], round = 25, players = 4, server = 't5_zombie_theater_1', map = 'zombie_theater'): GameEvent {
|
||||
return event === 'game_start'
|
||||
? { event, server, map, players: roster(players).map(({ name, guid }) => ({ name, guid })) }
|
||||
: { event, server, map, round, players: roster(players) };
|
||||
}
|
||||
export async function postTelemetry(body: unknown, contentType = 'application/json') {
|
||||
const { POST } = await import('../../src/routes/api/telemetry/+server.js');
|
||||
return POST({ request: new Request('http://localhost/api/telemetry', {
|
||||
method: 'POST', headers: { 'Content-Type': contentType }, body: JSON.stringify(body)
|
||||
}) });
|
||||
}
|
||||
@@ -1,100 +1,102 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { defaultPlan, parseBetaPlan, requirement, type BetaPlan } from '../../src/lib/beta-plan.js';
|
||||
import { applySample, applySignal, coveragePassed, emptyMetrics, evaluateRequirements, type TestSignal } from '../../src/lib/server/fleet/beta-evaluation.js';
|
||||
import { applySample, applyGameEvent, coveragePassed, emptyMetrics, evaluateRequirements } from '../../src/lib/server/fleet/beta-evaluation.js';
|
||||
import { gameEvent } from '../helpers/telemetry.js';
|
||||
|
||||
const signal = (event: TestSignal['event'], extra: Partial<TestSignal> = {}): TestSignal => ({ event, bootId: 'boot-1', sessionId: 'session-1', sequence: 0, round: 0, players: 4, name: '', ...extra });
|
||||
const planWith = (...kinds: Parameters<typeof requirement>[0][]): BetaPlan => ({ ...defaultPlan(), requirements: kinds.map((k, i) => requirement(k, `r${i}`)) });
|
||||
|
||||
describe('beta requirement evaluation', () => {
|
||||
it('copies a reactive preset without cloning proxies or mutating the saved requirements', () => {
|
||||
it('copies reactive presets without mutating saved requirements', () => {
|
||||
const source = defaultPlan();
|
||||
const proxied = new Proxy(source, { get(target, key, receiver) {
|
||||
return key === 'requirements' ? new Proxy(target.requirements, {}) : Reflect.get(target, key, receiver);
|
||||
} });
|
||||
const selected = parseBetaPlan(proxied);
|
||||
const selected = parseBetaPlan(new Proxy(source, {}));
|
||||
expect(selected).toEqual(source);
|
||||
selected.requirements[0].target = 15;
|
||||
selected.requirements.push(requirement('round', 'round'));
|
||||
expect(source.requirements).toHaveLength(1);
|
||||
expect(source.requirements[0].target).toBe(1);
|
||||
});
|
||||
it('validates plans without accepting unknown kinds, duplicate IDs, fractional counts or unsafe numbers', () => {
|
||||
it('rejects removed checks, duplicate IDs, fractional counts and unsafe numbers', () => {
|
||||
expect(parseBetaPlan(defaultPlan())).toEqual(defaultPlan());
|
||||
expect(() => parseBetaPlan({ ...defaultPlan(), requirements: [] })).toThrow();
|
||||
expect(() => parseBetaPlan({ ...defaultPlan(), timeoutHours: NaN })).toThrow();
|
||||
expect(() => parseBetaPlan({ ...defaultPlan(), requirements: [requirement('round', 'same'), requirement('round', 'same')] })).toThrow();
|
||||
for (const kind of ['feature', 'restart-budget', 'clean-logs']) {
|
||||
expect(() => parseBetaPlan({ ...defaultPlan(), requirements: [{ ...requirement('round', 'r'), kind }] })).toThrow();
|
||||
}
|
||||
expect(() => parseBetaPlan({ ...defaultPlan(), requirements: [{ ...requirement('round', 'r'), target: 1.5 }] })).toThrow();
|
||||
expect(() => parseBetaPlan({ ...defaultPlan(), requirements: [requirement('manual', 'constructor')] })).toThrow();
|
||||
});
|
||||
|
||||
it('counts only qualifying rounds and completed matches from the same session', () => {
|
||||
it('uses qualifying round and population together within the same game, including the final report', () => {
|
||||
const plan = planWith('round', 'matches');
|
||||
plan.requirements[0].minPlayers = 4;
|
||||
plan.requirements[1] = { ...plan.requirements[1], target: 2, minRound: 25, minPlayers: 4, minMinutes: 1 };
|
||||
const m = emptyMetrics();
|
||||
expect(applySignal(plan, m, signal('round_change', { sequence: 1, round: 50 }), 0)).toBe(false);
|
||||
applySignal(plan, m, signal('boot'), 0);
|
||||
applySignal(plan, m, signal('session_start'), 0);
|
||||
applySignal(plan, m, signal('round_change', { sequence: 1, round: 30, players: 1 }), 1000);
|
||||
applySignal(plan, m, signal('match_end', { sequence: 2 }), 61_000);
|
||||
applyGameEvent(plan, m, gameEvent('game_start'), 0);
|
||||
applyGameEvent(plan, m, gameEvent('round_change', 30, 1), 1000);
|
||||
applyGameEvent(plan, m, gameEvent('game_end', 30, 1), 61_000);
|
||||
expect(m.values.r0 ?? 0).toBe(0);
|
||||
expect(m.values.r1 ?? 0).toBe(0);
|
||||
applySignal(plan, m, signal('session_start', { sessionId: 'session-2' }), 62_000);
|
||||
applySignal(plan, m, signal('round_change', { sessionId: 'session-2', sequence: 1, round: 25 }), 63_000);
|
||||
applySignal(plan, m, signal('match_end', { sessionId: 'session-2', sequence: 2 }), 123_000);
|
||||
applyGameEvent(plan, m, gameEvent('game_start'), 62_000);
|
||||
applyGameEvent(plan, m, gameEvent('game_end', 25), 123_000);
|
||||
expect(m.values).toMatchObject({ r0: 25, r1: 1 });
|
||||
expect(applySignal(plan, m, signal('match_end', { sessionId: 'session-2', sequence: 3 }), 124_000)).toBe(false);
|
||||
applyGameEvent(plan, m, gameEvent('game_end', 25), 124_000);
|
||||
expect(m.values.r1).toBe(1);
|
||||
});
|
||||
|
||||
it('rejects out-of-order events and retired boots, while counting actual boot changes once', () => {
|
||||
const plan = planWith('feature', 'restart-budget');
|
||||
plan.requirements[0].event = 'perk.purchased';
|
||||
it('accepts ordinary rounds and game ends without boot events or a discovery handshake', () => {
|
||||
const plan = planWith('round', 'matches');
|
||||
const m = emptyMetrics();
|
||||
applySignal(plan, m, signal('boot'), 0);
|
||||
applySignal(plan, m, signal('session_start'), 0);
|
||||
applySignal(plan, m, signal('feature', { sequence: 2, name: 'perk.purchased' }), 1);
|
||||
expect(applySignal(plan, m, signal('feature', { sequence: 1, name: 'perk.purchased' }), 2)).toBe(false);
|
||||
applySignal(plan, m, signal('boot', { bootId: 'boot-2' }), 3);
|
||||
expect(applySignal(plan, m, signal('boot'), 4)).toBe(false);
|
||||
expect(m.values.r0).toBe(1);
|
||||
expect(evaluateRequirements(plan, m, 0)[1].failed).toBe(true);
|
||||
expect(m.restarts).toBe(1);
|
||||
applyGameEvent(plan, m, gameEvent('round_change'), 0);
|
||||
applyGameEvent(plan, m, gameEvent('game_end'), 1000);
|
||||
expect(m.values).toMatchObject({ r0: 25, r1: 1 });
|
||||
applyGameEvent(plan, m, gameEvent('game_start'), 2000);
|
||||
applyGameEvent(plan, m, gameEvent('game_end', 1), 3000);
|
||||
expect(m.values.r1).toBe(2);
|
||||
});
|
||||
|
||||
it('does not double count unqualified matches across log and telemetry sources', () => {
|
||||
it('does not combine games or maps to satisfy duration and round qualifiers', () => {
|
||||
const plan = planWith('matches');
|
||||
plan.requirements[0] = { ...plan.requirements[0], minRound: 25, minMinutes: 1 };
|
||||
const m = emptyMetrics();
|
||||
applySignal(plan, m, signal('boot'), 0);
|
||||
applySignal(plan, m, signal('session_start'), 0);
|
||||
applySignal(plan, m, signal('match_end', { sequence: 1 }), 1000);
|
||||
expect(m.values.r0).toBeUndefined(); // these requirements use incremental match logs
|
||||
applyGameEvent(plan, m, gameEvent('game_start'), 0);
|
||||
applyGameEvent(plan, m, gameEvent('round_change'), 1000);
|
||||
applyGameEvent(plan, m, gameEvent('game_start'), 60_000);
|
||||
applyGameEvent(plan, m, gameEvent('game_end', 1), 120_000);
|
||||
expect(m.values.r0 ?? 0).toBe(0);
|
||||
applyGameEvent(plan, m, gameEvent('game_end', 30, 4, 't5_zombie_theater_1', 'zombie_moon'), 180_000);
|
||||
expect(m.values.r0 ?? 0).toBe(0);
|
||||
});
|
||||
|
||||
it('integrates populated time and player-hours conservatively and excludes monitoring gaps', () => {
|
||||
const plan = planWith('populated-minutes', 'player-hours', 'healthy-minutes', 'population', 'sustained-population', 'failure-budget');
|
||||
plan.requirements[4].minPlayers = 4;
|
||||
it('derives participation only from game reports, even when rounds are many minutes apart', () => {
|
||||
const plan = planWith('populated-minutes', 'player-hours', 'population', 'sustained-population');
|
||||
plan.requirements[3].minPlayers = 4;
|
||||
const m = emptyMetrics();
|
||||
applySample(plan, m, 0, true, 4);
|
||||
applySample(plan, m, 30_000, true, 4);
|
||||
applySample(plan, m, 60_000, true, 4);
|
||||
expect(m.values).toMatchObject({ r0: 1, r1: 4 / 60, r2: 1, r3: 4, r4: 1 });
|
||||
applySample(plan, m, 300_000, true, 4);
|
||||
applySample(plan, m, 0, true);
|
||||
applySample(plan, m, 30_000, true);
|
||||
expect(m.values).toEqual({});
|
||||
applyGameEvent(plan, m, gameEvent('game_start'), 0);
|
||||
applyGameEvent(plan, m, gameEvent('round_change'), 600_000);
|
||||
expect(m.values).toMatchObject({ r0: 10, r1: 40 / 60, r2: 4, r3: 10 });
|
||||
applyGameEvent(plan, m, gameEvent('game_end', 26, 2), 1_200_000);
|
||||
expect(m.values).toMatchObject({ r0: 20, r1: 1, r3: 10 });
|
||||
applyGameEvent(plan, m, gameEvent('game_start'), 3_000_000);
|
||||
expect(m.values.r0).toBe(20);
|
||||
});
|
||||
|
||||
it('excludes monitoring gaps from healthy time and requires explicit manual sign-off', () => {
|
||||
const plan = planWith('healthy-minutes', 'failure-budget', 'manual');
|
||||
const m = emptyMetrics();
|
||||
applySample(plan, m, 0, true);
|
||||
applySample(plan, m, 60_000, true);
|
||||
expect(m.values.r0).toBe(1);
|
||||
expect(m.values.r2).toBe(0);
|
||||
applySample(plan, m, 330_000, false, 0);
|
||||
applySample(plan, m, 360_000, false, 0);
|
||||
applySample(plan, m, 300_000, true);
|
||||
expect(m.values.r0).toBe(0);
|
||||
applySample(plan, m, 330_000, false);
|
||||
applySample(plan, m, 360_000, false);
|
||||
expect(m.outages).toBe(1);
|
||||
expect(evaluateRequirements(plan, m, 0)[5].failed).toBe(true);
|
||||
});
|
||||
|
||||
it('requires readable logs, boot evidence and explicit manual sign-off', () => {
|
||||
const plan = planWith('clean-logs', 'restart-budget', 'manual');
|
||||
const m = emptyMetrics();
|
||||
expect(evaluateRequirements(plan, m, 0).every(r => !r.passed)).toBe(true);
|
||||
m.logAvailable = true; m.bootId = 'boot'; m.manual.r2 = 'Josh: HUD checked';
|
||||
expect(evaluateRequirements(plan, m, 0).every(r => r.passed)).toBe(true);
|
||||
m.logErrors = 1;
|
||||
expect(evaluateRequirements(plan, m, 0)[0].failed).toBe(true);
|
||||
expect(evaluateRequirements(plan, m, 0)[1].failed).toBe(true);
|
||||
expect(evaluateRequirements(plan, m, 0)[2].passed).toBe(false);
|
||||
m.manual.r2 = 'Josh: HUD checked';
|
||||
expect(evaluateRequirements(plan, m, 0)[2].passed).toBe(true);
|
||||
});
|
||||
|
||||
it('requires each selected coverage group even when many servers on one map pass', () => {
|
||||
|
||||
+119
-54
@@ -3,6 +3,7 @@ import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||
import { gameEvent, postTelemetry } from '../helpers/telemetry.js';
|
||||
import { defaultPlan, requirement, type BetaPlan } from '../../src/lib/beta-plan.js';
|
||||
|
||||
vi.mock('../../src/lib/server/integrations/komodo.js', () => ({ komodoRestart: vi.fn(), komodoInspectHealthy: vi.fn() }));
|
||||
@@ -77,56 +78,50 @@ describe('durable beta test plans', () => {
|
||||
expect(getBeta(id)?.state).toBe('ready-promote');
|
||||
});
|
||||
|
||||
it('ignores pre-beta log markers, handles partial lines, and persists cursors across app restarts', async () => {
|
||||
const id = await openPlan({ ...defaultPlan(), requirements: [requirement('matches', 'matches'), requirement('clean-logs', 'logs')] });
|
||||
it('uses script events instead of logs and keeps match progress across app restarts', async () => {
|
||||
await postTelemetry(gameEvent('game_end'));
|
||||
const id = await openPlan({ ...defaultPlan(), requirements: [requirement('matches', 'matches')] });
|
||||
await tick(); await tick();
|
||||
const { getBeta } = await import('../../src/lib/server/fleet/beta.js');
|
||||
const { testView } = await import('../../src/lib/server/fleet/beta-tests.js');
|
||||
expect(getBeta(id)?.state).toBe('running');
|
||||
expect(testView(id)?.servers[0].progress.map(p => p.current)).toEqual([0, 0]);
|
||||
const log = join(fixture.t5, names[0], 'logs/games_sp.log');
|
||||
await appendFile(log, 'InitGame:\n 0:00 J;new;1;Josh\nExitLevel: exe');
|
||||
await appendFile(join(fixture.t5, names[0], 'logs/games_sp.log'), 'InitGame:\n 0:00 J;new;1;Josh\nExitLevel: executed\n');
|
||||
await tick();
|
||||
expect(getBeta(id)?.state).toBe('running');
|
||||
expect(testView(id)?.servers[0].progress[0].current).toBe(0);
|
||||
await postTelemetry(gameEvent('game_start'));
|
||||
const { closeDb } = await import('../../src/lib/server/db/index.js');
|
||||
closeDb();
|
||||
await appendFile(log, 'cuted\n');
|
||||
await tick(); await tick();
|
||||
expect(getBeta(id)?.state).toBe('ready-promote');
|
||||
await postTelemetry(gameEvent('game_end'));
|
||||
await tick();
|
||||
expect(testView(id)?.servers[0].progress[0].current).toBe(1);
|
||||
await appendFile(join(fixture.t5, names[0], 'logs/console.log'), 'script runtime error: undefined variable\n');
|
||||
await tick();
|
||||
expect(getBeta(id)?.state).toBe('running');
|
||||
expect(testView(id)?.outcome).toBe('failed');
|
||||
});
|
||||
|
||||
it('accepts durable run-scoped round evidence and rejects stale runs and duplicate events', async () => {
|
||||
const id = await openPlan({ ...defaultPlan(), requirements: [requirement('round', 'round')] });
|
||||
await tick(); await tick();
|
||||
const { acceptTestSignal, currentRun, testView } = await import('../../src/lib/server/fleet/beta-tests.js');
|
||||
const run = currentRun(id, names[0])!;
|
||||
const base = { bootId: 'boot', sessionId: 'session', sequence: 0, round: 0, players: 4, name: '' };
|
||||
expect(acceptTestSignal(names[0], 'stale-run', 'old', { ...base, event: 'boot' }).accepted).toBe(false);
|
||||
expect(acceptTestSignal(names[0], run.id, 'boot', { ...base, event: 'boot' }).accepted).toBe(true);
|
||||
expect(acceptTestSignal(names[0], run.id, 'start', { ...base, event: 'session_start' }).accepted).toBe(true);
|
||||
const round = { ...base, event: 'round_change' as const, sequence: 1, round: 25 };
|
||||
expect(acceptTestSignal(names[0], run.id, 'round', round).accepted).toBe(true);
|
||||
expect(acceptTestSignal(names[0], run.id, 'round', round).accepted).toBe(false);
|
||||
await tick();
|
||||
expect(testView(id)?.outcome).toBe('passed');
|
||||
expect(testView(id)?.evidence.filter(e => e.kind === 'round_change')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('resets evidence after planned recovery and only passes after testing the new process', async () => {
|
||||
it('automatically credits ordinary rounds only to active deployed beta servers', async () => {
|
||||
const id = await openPlan({ ...defaultPlan(), waveSize: 1, requirements: [requirement('round', 'round')] }, names);
|
||||
await postTelemetry(gameEvent('round_change'));
|
||||
await tick(); await tick();
|
||||
const { testView } = await import('../../src/lib/server/fleet/beta-tests.js');
|
||||
expect(testView(id)?.servers[0].progress[0].current).toBe(0);
|
||||
await postTelemetry(gameEvent('round_change', 99, 4, names[1]));
|
||||
expect(testView(id)?.servers[0].progress[0].current).toBe(0);
|
||||
await postTelemetry(gameEvent('round_change'));
|
||||
expect(testView(id)?.servers[0].progress[0].current).toBe(25);
|
||||
expect(testView(id)?.evidence.filter(e => e.kind === 'round_change')).toHaveLength(1);
|
||||
await tick();
|
||||
expect(existsSync(scriptPath(names[1]))).toBe(true);
|
||||
});
|
||||
|
||||
it('resets evidence after planned recovery and consumes the same simple reports afterward', async () => {
|
||||
const id = await openPlan({ ...defaultPlan(), requirements: [requirement('population', 'players'), requirement('recovery', 'recovery')] });
|
||||
await tick(); await tick();
|
||||
const { currentRun, testView } = await import('../../src/lib/server/fleet/beta-tests.js');
|
||||
const first = currentRun(id, names[0])!;
|
||||
expect(testView(id)?.outcome).toBe('active');
|
||||
await tick();
|
||||
expect(testView(id)?.servers[0].progress[0].current).toBe(0);
|
||||
await postTelemetry(gameEvent('game_start'));
|
||||
await tick(); await tick();
|
||||
expect(currentRun(id, names[0])?.id).not.toBe(first.id);
|
||||
expect(currentRun(id, names[0])?.phase).toBe(1);
|
||||
expect(testView(id)?.servers[0].progress[0].current).toBe(0);
|
||||
await postTelemetry(gameEvent('game_start'));
|
||||
await tick();
|
||||
expect(testView(id)?.outcome).toBe('passed');
|
||||
});
|
||||
@@ -143,6 +138,7 @@ describe('durable beta test plans', () => {
|
||||
it('revokes readiness if health fails while awaiting manual promotion', async () => {
|
||||
const id = await openPlan({ ...defaultPlan(), requirements: [requirement('population', 'players'), requirement('failure-budget', 'outages')] });
|
||||
await tick(); await tick();
|
||||
await postTelemetry(gameEvent('game_start')); await tick();
|
||||
const { getBeta, promoteBeta } = await import('../../src/lib/server/fleet/beta.js');
|
||||
expect(getBeta(id)?.state).toBe('ready-promote');
|
||||
await tick(15, 0, false);
|
||||
@@ -152,10 +148,9 @@ describe('durable beta test plans', () => {
|
||||
|
||||
it('automatically rolls back only the deployed wave and queues safe rollback restarts', async () => {
|
||||
await writeFile(scriptPath(names[0]), 'original');
|
||||
const id = await openPlan({ ...defaultPlan(), waveSize: 1, failureAction: 'rollback', requirements: [requirement('round', 'round'), requirement('clean-logs', 'logs')] }, names);
|
||||
const id = await openPlan({ ...defaultPlan(), waveSize: 1, failureAction: 'rollback', requirements: [requirement('round', 'round'), requirement('failure-budget', 'outages')] }, names);
|
||||
await tick(); await tick();
|
||||
await appendFile(join(fixture.t5, names[0], 'logs/console.log'), 'fatal error: test\n');
|
||||
await tick();
|
||||
await tick(15, 0, false);
|
||||
const { getBeta } = await import('../../src/lib/server/fleet/beta.js');
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
expect(getBeta(id)?.state).toBe('rolled-back');
|
||||
@@ -176,6 +171,7 @@ describe('durable beta test plans', () => {
|
||||
it('retains artifact ownership while ready for promotion', async () => {
|
||||
const id = await openPlan({ ...defaultPlan(), requirements: [requirement('population', 'players')] });
|
||||
await tick(); await tick();
|
||||
await postTelemetry(gameEvent('game_start')); await tick();
|
||||
const { heldByBeta, ownerOf } = await import('../../src/lib/server/fleet/servers.js');
|
||||
expect(heldByBeta(names[0]).get('test.gsc')).toBe(id);
|
||||
expect(ownerOf(names[0], 'test.gsc')).toBe(id);
|
||||
@@ -185,32 +181,27 @@ describe('durable beta test plans', () => {
|
||||
expect(ownerOf(names[0], 'test.gsc')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not carry joins into a truncated log or deduplicate a genuinely new match after rotation', async () => {
|
||||
it('counts successive games with the same players and ignores repeated game ends', async () => {
|
||||
const id = await openPlan({ ...defaultPlan(), requirements: [{ ...requirement('matches', 'matches'), target: 2 }] });
|
||||
await tick(); await tick();
|
||||
const log = join(fixture.t5, names[0], 'logs/games_sp.log');
|
||||
await appendFile(log, 'InitGame:\n 0:00 J;first;1;Josh\nExitLevel: executed\n');
|
||||
await tick();
|
||||
// Same inode, contents replaced with a larger file: anchor detects truncation/regrowth.
|
||||
await writeFile(log, 'InitGame:\n 0:00 J;second;1;Josh\n' + 'padding\n'.repeat(30) + 'ExitLevel: executed\n');
|
||||
await tick();
|
||||
await postTelemetry(gameEvent('game_start'));
|
||||
await postTelemetry(gameEvent('game_end'));
|
||||
await postTelemetry(gameEvent('game_end'));
|
||||
const { testView } = await import('../../src/lib/server/fleet/beta-tests.js');
|
||||
expect(testView(id)?.servers[0].progress[0].current).toBe(1);
|
||||
await postTelemetry(gameEvent('game_start'));
|
||||
await postTelemetry(gameEvent('game_end'));
|
||||
await tick();
|
||||
expect(testView(id)?.servers[0].progress[0].current).toBe(2);
|
||||
expect(testView(id)?.outcome).toBe('passed');
|
||||
});
|
||||
|
||||
it('exposes current run tokens, validates session payloads, and records checklist sign-off through the API', async () => {
|
||||
it('records checklist sign-off and starts a fresh attempt without changing script payloads', async () => {
|
||||
const id = await openPlan({ ...defaultPlan(), requirements: [requirement('round', 'round'), requirement('manual', 'hud')] });
|
||||
await tick(); await tick();
|
||||
const telemetry = await import('../../src/routes/api/telemetry/+server.js');
|
||||
const context = await (await telemetry.GET({ url: new URL(`http://test/api/telemetry?server=${names[0]}`) })).json();
|
||||
expect(context.runs).toHaveLength(1);
|
||||
const body = { server: names[0], runId: context.runs[0].runId, eventId: 'boot', bootId: 'boot', sequence: 0, round: 0, players: 0, event: 'boot' };
|
||||
const post = (payload: unknown) => telemetry.POST({ request: new Request('http://test/api/telemetry', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }) });
|
||||
expect((await post({ ...body, eventId: null })).status).toBe(400);
|
||||
expect(await (await post(body)).json()).toMatchObject({ test: { accepted: true } });
|
||||
await post({ ...body, eventId: 'start', event: 'session_start', sessionId: 'game', players: 4 });
|
||||
await post({ ...body, eventId: 'round', event: 'round_change', sessionId: 'game', sequence: 1, round: 25, players: 4 });
|
||||
expect(telemetry).not.toHaveProperty('GET');
|
||||
expect(await (await postTelemetry(gameEvent('round_change'))).json()).not.toHaveProperty('test');
|
||||
const controls = await import('../../src/routes/api/betas/[id]/test/+server.js');
|
||||
const result = await controls.POST({ params: { id: String(id) }, request: new Request('http://test', { method: 'POST', body: JSON.stringify({ action: 'sign-off', server: names[0], requirementId: 'hud', note: 'Josh: HUD verified' }) }) });
|
||||
expect(result.status).toBe(200);
|
||||
@@ -219,8 +210,82 @@ describe('durable beta test plans', () => {
|
||||
expect(testView(id)?.outcome).toBe('passed');
|
||||
const retry = await controls.POST({ params: { id: String(id) }, request: new Request('http://test', { method: 'POST', body: JSON.stringify({ action: 'retry' }) }) });
|
||||
expect(retry.status).toBe(200);
|
||||
expect(await (await post({ ...body, eventId: 'late' })).json()).toMatchObject({ test: { accepted: false } });
|
||||
await postTelemetry(gameEvent('round_change'));
|
||||
expect(testView(id)?.servers).toHaveLength(0);
|
||||
await tick(); await tick();
|
||||
expect(testView(id)?.servers[0].progress[0].current).toBe(0);
|
||||
await postTelemetry(gameEvent('round_change'));
|
||||
expect(testView(id)?.servers[0].progress[0].current).toBe(25);
|
||||
});
|
||||
|
||||
it('keeps Fleet reporting during pauses without crediting paused gameplay or elapsed time', async () => {
|
||||
const id = await openPlan({ ...defaultPlan(), requirements: [{ ...requirement('populated-minutes', 'time'), target: 60 }] });
|
||||
await tick(); await tick();
|
||||
await postTelemetry(gameEvent('game_start'));
|
||||
vi.setSystemTime(Date.now() + 600_000);
|
||||
await postTelemetry(gameEvent('round_change'));
|
||||
const { testView } = await import('../../src/lib/server/fleet/beta-tests.js');
|
||||
expect(testView(id)?.servers[0].progress[0].current).toBe(10);
|
||||
const controls = await import('../../src/routes/api/betas/[id]/test/+server.js');
|
||||
const control = (action: string) => controls.POST({ params: { id: String(id) }, request: new Request('http://test', {
|
||||
method: 'POST', body: JSON.stringify({ action })
|
||||
}) });
|
||||
expect((await control('pause')).status).toBe(200);
|
||||
vi.setSystemTime(Date.now() + 3_600_000);
|
||||
await postTelemetry(gameEvent('game_end', 50));
|
||||
const { listTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
|
||||
expect(listTelemetry()[0]).toMatchObject({ event: 'game_end', round: 50 });
|
||||
expect(testView(id)?.servers[0].progress[0].current).toBe(10);
|
||||
expect((await control('resume')).status).toBe(200);
|
||||
await postTelemetry(gameEvent('round_change', 1));
|
||||
expect(testView(id)?.servers[0].progress[0].current).toBe(10);
|
||||
vi.setSystemTime(Date.now() + 600_000);
|
||||
await postTelemetry(gameEvent('round_change', 2));
|
||||
expect(testView(id)?.servers[0].progress[0].current).toBe(20);
|
||||
});
|
||||
|
||||
it('migrates existing unsupported checks into review items and resets active progress once', async () => {
|
||||
const id = await openPlan({ ...defaultPlan(), requirements: [requirement('round', 'round')] });
|
||||
await tick(); await tick();
|
||||
await postTelemetry(gameEvent('round_change'));
|
||||
await tick();
|
||||
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
|
||||
const oldPlan = { ...defaultPlan(), requirements: [requirement('round', 'round'),
|
||||
...['feature', 'restart-budget', 'clean-logs'].map((kind, i) => ({ ...requirement('manual', `old-${i}`), kind, event: 'perk.purchased' }))] };
|
||||
db().prepare('UPDATE beta_test_plans SET plan = ? WHERE beta_id = ?').run(JSON.stringify(oldPlan), id);
|
||||
db().prepare('INSERT INTO beta_test_presets (name, plan) VALUES (?, ?)').run('Old plan', JSON.stringify(oldPlan));
|
||||
db().prepare("DELETE FROM settings WHERE key = 'game_telemetry_v2'").run();
|
||||
closeDb();
|
||||
const { testView } = await import('../../src/lib/server/fleet/beta-tests.js');
|
||||
const { getBeta } = await import('../../src/lib/server/fleet/beta.js');
|
||||
const view = testView(id)!;
|
||||
expect(view.plan.requirements.map(r => r.kind)).toEqual(['round', 'manual', 'manual', 'manual']);
|
||||
expect(view.plan.requirements.some(r => 'event' in r)).toBe(false);
|
||||
expect(view.servers[0].progress.every(p => !p.passed)).toBe(true);
|
||||
expect(view.evidence.some(e => e.kind === 'round_change')).toBe(true);
|
||||
expect(getBeta(id)?.state).toBe('running');
|
||||
const presets = await import('../../src/routes/api/betas/presets/+server.js');
|
||||
const saved = (await presets.GET().json()).presets.find((p: { name: string }) => p.name === 'Old plan');
|
||||
expect(saved.plan).toEqual(view.plan);
|
||||
await postTelemetry(gameEvent('round_change'));
|
||||
closeDb();
|
||||
expect(testView(id)?.servers[0].progress[0].current).toBe(25);
|
||||
});
|
||||
|
||||
it('uses game_end for the legacy match gate without reading logs', async () => {
|
||||
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
|
||||
const { openBeta, getBeta, listBetaServers } = await import('../../src/lib/server/fleet/beta.js');
|
||||
const { script, version } = createScript({ name: 'Legacy beta', targetRelPath: 'legacy.gsc', content: Buffer.from('main() {}') });
|
||||
const { betaId } = await openBeta({ scripts: [{ scriptId: script.id, scriptVersionId: version.id }], servers: [names[0]], waitForHealth: true, waitForMatch: true });
|
||||
await tick(); await tick();
|
||||
expect(listBetaServers(betaId)[0].stage).toBe('awaiting-match');
|
||||
await appendFile(join(fixture.t5, names[0], 'logs/games_sp.log'), 'ExitLevel: executed\n');
|
||||
await tick();
|
||||
expect(getBeta(betaId)?.state).toBe('running');
|
||||
await postTelemetry(gameEvent('game_end'));
|
||||
await postTelemetry(gameEvent('game_start'));
|
||||
await tick();
|
||||
expect(getBeta(betaId)?.state).toBe('ready-promote');
|
||||
});
|
||||
|
||||
it('stores validated presets as copies and rejects malformed plans', async () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { currentRound, fleetIdentity, matchesFleet, watchSatisfied, type FleetServer, type FleetOperation } from '../../src/lib/fleet-overview.js';
|
||||
import { gameEvent } from '../helpers/telemetry.js';
|
||||
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||
|
||||
const now = Date.parse('2026-09-18T12:00:00Z');
|
||||
@@ -42,7 +43,7 @@ describe('Fleet discovery and match freshness', () => {
|
||||
expect(matchesFleet({ ...server, health: 'starting' }, '', 'attention', '', [], [], now)).toBe(false);
|
||||
});
|
||||
it('only spotlights fresh telemetry from the current occupied match', () => {
|
||||
const report = { server: server.name, round: 38, players: 4, receivedAt: iso(-1000) };
|
||||
const report = { ...gameEvent('round_change', 38, 4, server.name), receivedAt: iso(-1000) };
|
||||
expect(currentRound(server, report, now)).toBe(38);
|
||||
for (const change of [{ receivedAt: iso(-121_000) }, { receivedAt: iso(-61_000) }, { receivedAt: iso(60_000) }]) {
|
||||
expect(currentRound(server, { ...report, ...change }, now)).toBeNull();
|
||||
|
||||
@@ -79,13 +79,12 @@ describe('T6 config lifecycle in a mixed fleet', () => {
|
||||
});
|
||||
|
||||
it('reads the T6 game log and keeps log path traversal checks', async () => {
|
||||
const { resolveLogPath, gameLogKey } = await import('../../src/lib/server/fleet/logs.js');
|
||||
const { scanMatchState } = await import('../../src/lib/server/fleet/match-log.js');
|
||||
const { resolveLogPath, gameLogKey, tailLog } = await import('../../src/lib/server/fleet/logs.js');
|
||||
const logs = join(fixture.t6, town, 'logs');
|
||||
await mkdir(logs, { recursive: true });
|
||||
await writeFile(join(logs, 'games_zm.log'), '0:00 InitGame:\n0:01 J;123;0;Player\n');
|
||||
expect(resolveLogPath(town, gameLogKey(town))).toBe(resolve(logs, 'games_zm.log'));
|
||||
expect(await scanMatchState(town)).toMatchObject({ kind: 'in-progress' });
|
||||
expect((await tailLog(resolveLogPath(town, gameLogKey(town))!, 10))?.lines).toContain('0:01 J;123;0;Player');
|
||||
expect(resolveLogPath('t6_/../../escape', 'games_zm')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
||||
import { gameEvent, postTelemetry as post, roster } from '../helpers/telemetry.js';
|
||||
|
||||
let fixture: Fixture;
|
||||
beforeEach(async () => {
|
||||
@@ -10,42 +11,60 @@ beforeEach(async () => {
|
||||
});
|
||||
afterEach(async () => fixture.cleanup());
|
||||
|
||||
const payload = { event: 'round_change', server: 't5_zombie_theater_1', round: 17, players: 4 };
|
||||
async function post(body: unknown, contentType = 'application/json') {
|
||||
const { POST } = await import('../../src/routes/api/telemetry/+server.js');
|
||||
return POST({ request: new Request('http://localhost/api/telemetry', {
|
||||
method: 'POST', headers: { 'Content-Type': contentType }, body: JSON.stringify(body)
|
||||
}) });
|
||||
}
|
||||
const payload = gameEvent('round_change', 17);
|
||||
|
||||
it('persists reports, broadcasts updates, and leaves RCON safety data untouched', async () => {
|
||||
it('persists full player stats, broadcasts updates, and leaves RCON safety data untouched', async () => {
|
||||
const { subscribe } = await import('../../src/lib/server/pipeline/bus.js');
|
||||
const listener = vi.fn();
|
||||
const unsubscribe = subscribe(listener);
|
||||
expect((await post(payload)).status).toBe(200);
|
||||
expect(listener).toHaveBeenCalledWith(expect.objectContaining({ type: 'server-telemetry' }));
|
||||
expect(await (await post(payload)).json()).toEqual({ ok: true, telemetry: { ...payload, receivedAt: expect.any(String) } });
|
||||
expect(listener).toHaveBeenCalledWith({ type: 'server-telemetry', entries: [{ ...payload, receivedAt: expect.any(String) }] });
|
||||
unsubscribe();
|
||||
// A new match can legitimately report a lower round; retries overwrite one row.
|
||||
await post({ ...payload, round: 1 });
|
||||
await post({ ...payload, round: 1 });
|
||||
const { closeDb } = await import('../../src/lib/server/db/index.js');
|
||||
closeDb();
|
||||
const { listTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
|
||||
expect(listTelemetry()).toEqual([expect.objectContaining({ round: 1, players: 4 })]);
|
||||
expect(listTelemetry()).toEqual([{ ...payload, round: 1, receivedAt: expect.any(String) }]);
|
||||
const { getServer } = await import('../../src/lib/server/fleet/servers.js');
|
||||
expect(getServer(payload.server)?.last_players).toBeNull();
|
||||
const { GET } = await import('../../src/routes/api/events/servers/+server.js');
|
||||
const response = await GET();
|
||||
const reader = response.body!.getReader();
|
||||
await reader.read(); // greeting
|
||||
const snapshot = await reader.read();
|
||||
expect(new TextDecoder().decode(snapshot.value)).toContain('"round":1');
|
||||
const reader = (await GET()).body!.getReader();
|
||||
await reader.read();
|
||||
const snapshot = new TextDecoder().decode((await reader.read()).value);
|
||||
expect(snapshot).toContain('"round":1');
|
||||
expect(snapshot).toContain('"headshots":50');
|
||||
expect(snapshot).toContain('"guid":"12345678901234560"');
|
||||
await reader.cancel();
|
||||
});
|
||||
|
||||
it.each([null, [], {}, { ...payload, event: 'other' }, { ...payload, round: -1 },
|
||||
{ ...payload, round: 1.5 }, { ...payload, round: '17' }, { ...payload, players: -1 },
|
||||
{ ...payload, players: null }])('rejects invalid telemetry: %j', async (body) => {
|
||||
it('accepts all three events without an active beta and updates match lifecycle', async () => {
|
||||
const { getServer } = await import('../../src/lib/server/fleet/servers.js');
|
||||
const { listTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
|
||||
const start = gameEvent('game_start');
|
||||
expect((await post(start)).status).toBe(200);
|
||||
expect(listTelemetry()).toEqual([{ ...start, receivedAt: expect.any(String) }]);
|
||||
expect(getServer(start.server)?.game_started_at).toBeTruthy();
|
||||
expect(getServer(start.server)?.game_ended_at).toBeNull();
|
||||
expect((await post(payload)).status).toBe(200);
|
||||
const end = gameEvent('game_end', 18);
|
||||
expect((await post(end)).status).toBe(200);
|
||||
expect(getServer(start.server)?.game_ended_at).toBeTruthy();
|
||||
expect(listTelemetry()[0]).toMatchObject(end);
|
||||
await post(start);
|
||||
expect(listTelemetry()[0]).not.toHaveProperty('round');
|
||||
expect(getServer(start.server)?.game_ended_at).toBeNull();
|
||||
expect((await post(gameEvent('game_start', 0, 0))).status).toBe(200);
|
||||
expect((await post(gameEvent('game_end', 0, 0))).status).toBe(200);
|
||||
});
|
||||
|
||||
it.each([null, [], {}, ...['boot', 'session_start', 'match_end', 'feature', 'other'].map(event => ({ ...payload, event })),
|
||||
{ ...payload, map: '' }, { ...payload, map: undefined }, { ...payload, round: -1 },
|
||||
{ ...payload, round: 1.5 }, { ...payload, round: '17' }, { ...payload, players: 4 }, { ...payload, players: null },
|
||||
{ ...payload, players: [null] }, { ...payload, players: [{ name: 'Josh', guid: '123' }] },
|
||||
...['name', 'guid', 'kills', 'headshots', 'downs', 'revives', 'score'].map(field => ({ ...payload, players: [{ ...roster(1)[0], [field]: null }] })),
|
||||
{ ...payload, players: [{ ...roster(1)[0], guid: 12345678901234567 }] },
|
||||
{ ...payload, players: [{ ...roster(1)[0], score: -1 }] }
|
||||
])('rejects invalid telemetry: %j', async (body) => {
|
||||
expect((await post(body)).status).toBe(400);
|
||||
});
|
||||
|
||||
@@ -63,3 +82,15 @@ it('rejects malformed JSON', async () => {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{'
|
||||
}) })).status).toBe(400);
|
||||
});
|
||||
|
||||
it('migrates old round-only storage without fabricating player identities', async () => {
|
||||
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
|
||||
db().exec('DROP TABLE server_telemetry; CREATE TABLE server_telemetry (server TEXT PRIMARY KEY, round INTEGER NOT NULL, players INTEGER NOT NULL, received_at TEXT NOT NULL)');
|
||||
db().prepare('INSERT INTO server_telemetry VALUES (?, ?, ?, ?)').run(payload.server, 99, 4, new Date().toISOString());
|
||||
closeDb();
|
||||
const { listTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
|
||||
expect(listTelemetry()).toEqual([]);
|
||||
await post(payload);
|
||||
closeDb();
|
||||
expect(listTelemetry()[0]).toMatchObject(payload);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user