Restore reproducible isolated UI fixtures

This commit is contained in:
2026-10-07 20:08:02 -04:00
parent 70185eb98c
commit 5b3e8cb1a3
6 changed files with 632 additions and 1 deletions
+48
View File
@@ -26,6 +26,54 @@ separate database when testing enrollment. HTTP login is for local development;
production agent transport requires HTTPS. See [authentication](../guides/authentication.md)
for access and recovery details.
## Synthetic UI preview
Create a disposable fixture without connecting game hosts:
```sh
fixture_parent=$(mktemp -d "${TMPDIR:-/tmp}/ec-ui.XXXXXX")
npm run seed:fixture -- --db "$fixture_parent/preview/cherry.db" --watch
```
The command prints the generated paths. Leave that terminal open to refresh
synthetic observations every 15 seconds. In a second terminal, load the printed
environment file and start the controller on localhost:
```sh
source /absolute/path/printed/above/preview.env
npm run dev -- --host 127.0.0.1 --port 3000
```
Open `http://127.0.0.1:3000/login`. The private `fixture.json` next to the database
contains the `demo` account's generated password. It is not printed in command
output. To run a fixed snapshot instead of a live feeder, use a fresh directory
and replace `--watch` with `--now 2026-10-07T12:00:00Z`. Domain data is deterministic
for that timestamp; account credentials are always generated separately. Old
snapshots naturally show stale observations when viewed against today's clock.
The fixture contains 3 hosts, 12 canonical Black Ops I/II servers, and 500 match
records: 494 historical and 6 live. It includes solo through four-player squads,
unknown starting counts, enough players and matches to exercise pagination,
rosters, seven days of utilization and capacity samples, and retained Activity
from campaigns, betas, host operations, maintenance, recovery, and autoscaling.
The fleet has 6 live matches, 2 empty lobbies, a preparing server, and 3 problems:
a stopped server, a server not responding, and a server on the offline host.
Script and config downloads contain harmless synthetic text.
`--db` must be an absolute path in a **new directory** whose parent exists. The
seeder refuses existing directories, databases, sidecars and symlinks, and never
uses an inherited `DB_PATH`. It runs the application's database migrations with
isolated paths, without starting the web server or workers. A failed seed removes
only its newly reserved directory. Completed fixtures remain after Ctrl+C.
The preview controller runs its usual local workers. Automatic maintenance and
autoscaling are paused; all seeded operations are terminal. Hostnames, runtime
images, library IDs and credentials are synthetic, so there are no runnable game
files or authenticated agents. Use this environment only for local UI review.
The feeder checks the fixture marker and restores only its known observations,
including the deliberately offline host. Stop both terminals when finished and
remove the generated fixture directory.
## Checks and builds
```sh
+1 -1
View File
@@ -13,7 +13,7 @@
"test:watch": "vitest",
"benchmark:leaderboard": "node scripts/benchmark-leaderboard.mjs",
"diagnostics:database": "node scripts/database-diagnostics.mjs",
"seed:fixture": "tsx scripts/seed-fixture.ts",
"seed:fixture": "node --import tsx scripts/seed-fixture.ts",
"agent:package": "python3 agent/package_release.py",
"prebuild": "npm run agent:package",
"test:agent": "python3 -m unittest discover -s agent/tests -v",
+235
View File
@@ -0,0 +1,235 @@
import { createHash } from 'node:crypto';
import type Database from 'better-sqlite3';
import { zombieMapBySlug } from '../src/lib/map-aliases.js';
export const FIXTURE_KEY = 'development_fixture_v1';
export const FIXTURE_VERSION = 'electric-cherry-ui-v1';
const MINUTE = 60_000;
const DAY = 24 * 60 * MINUTE;
const GIB = 1024 ** 3;
const image = 'fixture.invalid/electric-cherry/preview:local';
type Scenario = { game: 't5' | 't6'; slug: string; players: number; round: number; minutes: number; state: 'active' | 'idle' | 'stopped' | 'stale' | 'starting' | 'host-offline' };
const scenarios: Scenario[] = [
{ game: 't5', slug: 'theater', players: 4, round: 27, minutes: 76, state: 'active' },
{ game: 't5', slug: 'cosmodrome', players: 2, round: 16, minutes: 39, state: 'active' },
{ game: 't5', slug: 'moon', players: 0, round: 0, minutes: 0, state: 'idle' },
{ game: 't5', slug: 'temple', players: 3, round: 32, minutes: 84, state: 'active' },
{ game: 't5', slug: 'pentagon', players: 0, round: 0, minutes: 0, state: 'stopped' },
{ game: 't5', slug: 'coast', players: 0, round: 0, minutes: 0, state: 'stale' },
{ game: 't6', slug: 'tomb', players: 3, round: 22, minutes: 58, state: 'active' },
{ game: 't6', slug: 'town', players: 2, round: 12, minutes: 23, state: 'active' },
{ game: 't6', slug: 'buried', players: 0, round: 0, minutes: 0, state: 'idle' },
{ game: 't6', slug: 'prison', players: 4, round: 41, minutes: 119, state: 'active' },
{ game: 't6', slug: 'nuked', players: 0, round: 0, minutes: 0, state: 'starting' },
{ game: 't6', slug: 'highrise', players: 0, round: 0, minutes: 0, state: 'host-offline' }
];
export const fixtureServers = scenarios.map((scenario, index) => ({ ...scenario,
name: `${scenario.game}_zombie_${scenario.slug}_1`, host: `fixture-host-${index === 11 ? 3 : index % 2 + 1}`,
instance: `fixture-instance-${index + 1}`, port: 28960 + index,
map: zombieMapBySlug(scenario.game, scenario.slug)!
}));
const names = ['Jamie Cross', 'Alex', 'Sam', 'Morgan', 'Riley', 'Taylor', 'Jordan', 'Casey', 'Quinn', 'Avery', 'Drew', 'Kai'];
const iso = (at: number) => new Date(at).toISOString();
const player = (index: number) => ({ guid: index === 0 ? 'fixture-jamie' : `fixture-player-${index}`,
name: index === 0 ? names[0] : `${names[index % names.length]} ${Math.floor(index / names.length) + 1}` });
const rosterFor = (server: typeof fixtureServers[number], index: number) => Array.from({ length: server.players }, (_, slot) => ({ ...player(slot === 0 ? 0 : index * 4 + slot),
ping: 29 + slot * 7, kills: (slot + 1) * 137, headshots: (slot + 1) * 41, downs: slot % 2, revives: slot, score: (slot + 1) * 4100 }));
const seenAt = (server: Scenario, now: number) => iso(now - (['stopped', 'host-offline'].includes(server.state) ? 15 * MINUTE : server.state === 'stale' ? 105_000 : 0));
const containerState = (server: Scenario) => server.state === 'stopped' ? 'exited' : server.state === 'starting' ? 'starting' : 'running';
const instanceStates = (host: string, now: number) => fixtureServers.filter(server => server.host === host).map(server => ({
instanceId: server.instance, generation: 1, state: containerState(server), healthy: !['stopped', 'starting'].includes(server.state),
observedAt: iso(now - (host === 'fixture-host-3' ? 10 * MINUTE : 0))
}));
/** Uses only a caller-owned, fully migrated database. No host or worker code is imported. */
export function seedFixtureData(handle: Database.Database, now: number): void {
if (!Number.isFinite(now)) throw new Error('Invalid fixture time.');
const occupied = ['servers', 'leaderboard_matches', 'ec_hosts', 'auth_users', 'scripts', 'server_deployments', 'deployments', 'betas', 'game_file_libraries']
.some(table => handle.prepare(`SELECT 1 FROM ${table} LIMIT 1`).get());
if (occupied || handle.prepare('SELECT 1 FROM settings WHERE key=?').get(FIXTURE_KEY)) throw new Error('Fixture seeding requires a new database.');
const insert = (table: string, values: Record<string, unknown>) => {
const keys = Object.keys(values);
handle.prepare(`INSERT INTO ${table} (${keys.join(',')}) VALUES (${keys.map(() => '?').join(',')})`).run(...Object.values(values));
};
const started = iso(now - 30 * DAY);
const rememberPlayer = handle.prepare(`INSERT INTO leaderboard_players(game,guid,name,first_seen_at,last_seen_at) VALUES(?,?,?,?,?)
ON CONFLICT(game,guid) DO UPDATE SET first_seen_at=MIN(first_seen_at,excluded.first_seen_at),last_seen_at=MAX(last_seen_at,excluded.last_seen_at)`);
handle.transaction(() => {
insert('settings', { key: FIXTURE_KEY, value: FIXTURE_VERSION });
// Preview observations must never opt into automatic lifecycle changes.
for (const [key, value] of Object.entries({ maintenance_paused: '1', maintenance_health: '0', maintenance_drift: '0', maintenance_updates: '0', watchdog_enabled: '0', autoscaling_paused: '1' })) {
handle.prepare('INSERT INTO settings(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value').run(key, value);
}
for (const [index, name] of ['Chicago · Primary', 'Dallas · Overflow', 'Frankfurt · Europe'].entries()) {
const id = `fixture-host-${index + 1}`;
insert('ec_hosts', { id, name, hostname: `host-${index + 1}.fixture.invalid`, created_at: started, enrolled_at: started,
last_heartbeat: iso(now - (index === 2 ? 10 * MINUTE : 0)), inventory: JSON.stringify({ protocolVersion: 1, agentVersion: '1.7.0', cpuCount: 16,
cpuUsagePercent: 24 + index * 9, memoryTotal: 32 * GIB, memoryAvailable: (20 - index * 3) * GIB,
storageTotal: 500 * GIB, storageAvailable: 240 * GIB, dockerRunning: true, directoriesWritable: true,
runtimeImages: [image], libraries: [{ id: 'fixture-t5', state: 'ready' }, { id: 'fixture-t6', state: 'ready' }],
capabilities: ['instance.heartbeat-state'], instanceStates: instanceStates(id, now) }) });
// Deliberately not a token hash; no bearer token can authenticate as these agents.
insert('ec_host_credentials', { id: `fixture-credential-${index + 1}`, host_id: id, token_hash: `invalid-fixture-${index + 1}`, created_at: started });
for (let minute = 0; minute < 7 * 1440; minute += 15) {
insert('ec_host_resource_samples', { host_id: id, sampled_at: iso(now - minute * MINUTE), cpu_count: 16,
cpu_usage_percent: 28 + index * 6 + 10 * Math.sin(minute / 95), memory_total: 32 * GIB,
memory_available: Math.round((18 - index * 2 + Math.sin(minute / 110)) * GIB) });
}
}
let liveId = 495;
for (const [index, server] of fixtureServers.entries()) {
const beginning = iso(now - server.minutes * MINUTE - 12_000);
const roster = rosterFor(server, index);
insert('servers', { name: server.name, game: server.game, map: server.map.canonical, map_slug: server.slug, game_mode: 'zombie',
game_port: server.port, game_id: server.game === 't5' ? 't5sp' : 't6zm', image,
created_by: 'development fixture', created_at: started, last_players: server.players, last_map: server.map.canonical, last_gametype: 'zclassic',
last_roster: JSON.stringify(roster), last_seen_at: seenAt(server, now),
last_checked_at: iso(now), last_error: server.state === 'stale' ? 'timeout: no reply from game server' : null,
rcon_failures: server.state === 'stale' ? 2 : 0,
container_state: containerState(server),
container_checked_at: iso(now), desired_state: server.state === 'stopped' ? 'stopped' : 'running',
active_since: server.players ? beginning : null, inactive_since: server.players ? null : iso(now - 5 * MINUTE),
game_started_at: server.players ? beginning : null });
insert('native_instances', { id: server.instance, server: server.name, host_id: server.host,
library_id: `fixture-${server.game}`, image, port: server.port, created_at: started });
insert('plutonium_versions', { server: server.name, revision: 4878, checked_at: iso(now), error: null });
if (server.state === 'active' || server.state === 'idle') {
const matchId = server.players ? liveId++ : null;
const payload = { server: server.name, map: server.map.canonical, event: 'heartbeat', round: server.round, players: roster, receivedAt: iso(now),
timing: { matchId, players: Object.fromEntries(roster.map(p => [p.guid, { serverJoinedAt: beginning, matchJoinedAt: beginning, roundStarted: 1 }])) } };
insert('server_telemetry', { server: server.name, round: server.round, players: server.players, received_at: iso(now), payload: JSON.stringify(payload) });
if (matchId !== null) {
insert('leaderboard_matches', { id: matchId, server: server.name, game: server.game, map: server.map.canonical,
map_key: server.slug, map_name: server.map.friendly, started_at: beginning, last_received_at: iso(now), status: 'active',
start_observed: 1, start_players: server.players, highest_round: server.round, last_event: 'heartbeat', last_fingerprint: `fixture-live-${index}` });
for (const p of roster) {
rememberPlayer.run(server.game, p.guid, p.name, beginning, iso(now));
insert('leaderboard_match_players', { match_id: matchId, guid: p.guid, highest_round: server.round, rounds_survived: server.round,
kills: p.kills, headshots: p.headshots, downs: p.downs, revives: p.revives, best_score: p.score,
last_round: server.round, present: 1, has_stats: 1, completed: 0, first_seen_at: beginning, last_seen_at: iso(now),
first_observed_round: 1, present_at_start: 1 });
}
}
}
for (const phase of ['startup', 'running']) {
for (let minute = 0; minute < 60; minute++) insert('ec_instance_resource_samples', {
host_id: server.host, instance_id: server.instance, generation: 1, phase, bucket: iso(now - minute * MINUTE),
cpu_cores: phase === 'startup' ? 0.7 + index / 40 : 0.25 + index / 50,
memory_bytes: Math.round((phase === 'startup' ? 1.1 : 0.7) * GIB) });
}
}
for (let index = 0; index < 494; index++) {
// A large Kino duo cohort exercises ranking and profile pagination.
const server = fixtureServers[index < 64 ? 0 : index % fixtureServers.length];
const squad = index < 64 ? 2 : Math.floor(index / 12) % 4 + 1;
const unknownStart = index >= 64 && index % 37 === 0;
const interrupted = index >= 64 && index % 31 === 0;
const end = now - (180 + index * 37) * MINUTE;
const beginning = end - (30 + index % 100) * MINUTE;
const round = 10 + index * 7 % 56;
insert('leaderboard_matches', { id: index + 1, server: server.name, game: server.game, map: server.map.canonical,
map_key: server.slug, map_name: server.map.friendly, started_at: iso(beginning), ended_at: iso(end), last_received_at: iso(end),
status: interrupted ? 'interrupted' : 'completed', start_observed: unknownStart ? 0 : 1, start_players: unknownStart ? null : squad,
highest_round: round, last_event: interrupted ? 'interrupted' : 'game_end', last_fingerprint: `fixture-match-${index + 1}` });
for (let slot = 0; slot < squad; slot++) {
const p = player(slot === 0 ? 0 : (index + slot * 19) % 80 + 1);
rememberPlayer.run(server.game, p.guid, p.name, iso(beginning), iso(end));
insert('leaderboard_match_players', { match_id: index + 1, guid: p.guid, highest_round: round, rounds_survived: round - (unknownStart ? 4 : 0),
kills: round * 35 + slot * 71, headshots: round * 9, downs: index % 4, revives: slot + index % 3,
best_score: round * 2100 + slot * 1000, last_round: round, present: slot > 0 && index % 9 === 0 ? 0 : 1, has_stats: 1,
completed: !interrupted && !(slot > 0 && index % 9 === 0) ? 1 : 0, first_seen_at: iso(beginning), last_seen_at: iso(end),
first_observed_round: unknownStart ? 5 : 1, present_at_start: unknownStart ? null : 1 });
}
}
const metric = handle.prepare('INSERT INTO metric_samples(at,server,game,map,players,capacity) VALUES(?,?,?,?,?,4)');
for (let minute = 0; minute < 7 * 1440; minute++) {
for (const [index, server] of fixtureServers.entries()) {
const count = Math.floor((Math.sin(minute / 90 + index) + 1) * 2.49);
metric.run(now - minute * MINUTE, server.name, server.game, server.map.friendly,
minute % 720 < 8 && index === 5 ? null : count);
}
}
for (const scope of ['*', ...[1, 2, 3].map(index => `fixture-host-${index}`)]) for (const phase of ['startup', 'running']) {
insert('ec_resource_allowances', { scope, phase, cpu_cores: phase === 'startup' ? 1 : 0.5,
memory_bytes: Math.round((phase === 'startup' ? 1.2 : 0.8) * GIB), samples: 240,
window_start: iso(now - DAY), observed_at: iso(now), computed_at: iso(now), usage_margin: 1 });
}
// Real artifact blobs, so details and downloads work without game files.
for (const [index, kind] of ['script', 'baseline', 'config'].entries()) {
const content = Buffer.from(kind === 'script' ? '// Synthetic UI fixture; no gameplay behavior.\n' : '// Synthetic UI fixture configuration.\nset sv_maxclients 4\n');
const hash = createHash('sha256').update(content).digest('hex');
handle.prepare('INSERT OR IGNORE INTO file_blobs(sha256,size,content) VALUES(?,?,?)').run(hash, content.length, content);
insert('scripts', { id: index + 1, name: ['Fixture telemetry', 'Fixture Black Ops I baseline', 'Fixture lobby settings'][index],
target_rel_path: index === 0 ? 'fixture.gsc' : index === 1 ? 'dedicated_sp.cfg' : 'fixture.cfg',
scope_type: 'game', scope_value: 't5', current_version_id: index + 1, kind, created_at: started });
insert('script_versions', { id: index + 1, script_id: index + 1, version: 1, sha256: hash, content_hash: hash,
size: content.length, notes: 'Synthetic preview content.', state: 'tested', kind, uploaded_at: started });
}
for (let index = 0; index < 36; index++) {
const failed = index % 7 === 0;
const server = fixtureServers[failed ? 5 : index % fixtureServers.length];
const id = `fixture-operation-${index + 1}`;
const state = failed ? 'failed' : index % 11 === 0 ? 'cancelled' : 'done';
insert('server_deployments', { id, server: server.name, action: ['restart', 'start', 'rebuild', 'stop'][index % 4], state,
stage: failed ? 'Verification failed' : state === 'done' ? 'Completed' : 'Cancelled before dispatch',
started_at: iso(now - (60 + index * 2) * MINUTE), finished_at: iso(now - (59 + index * 2) * MINUTE), triggered_by: 'Fixture operator',
error: failed ? 'Synthetic verification timeout. No host command was sent.' : null, warnings: '[]' });
insert('native_provisioning', { deployment_id: id, instance_id: server.instance, generation: 1 });
insert('server_deployment_events', { deployment_id: id, created_at: iso(now - (59 + index * 2) * MINUTE), message: 'Synthetic retained history; no command was sent.' });
}
for (let index = 0; index < 8; index++) {
const server = fixtureServers[index % fixtureServers.length];
insert('ec_host_operations', { id: `fixture-host-command-${index + 1}`, host_id: server.host, instance_id: server.instance,
server_name: server.name, kind: index % 2 ? 'instance.inspect' : 'instance.restart', payload: '{}',
state: index % 3 ? 'completed' : 'failed', result: '{}', error: index % 3 ? null : 'Synthetic command failure.',
created_at: iso(now - (90 + index * 3) * MINUTE), updated_at: iso(now - (89 + index * 3) * MINUTE), source: 'Fixture operator' });
}
for (let index = 0; index < 3; index++) {
const server = fixtureServers[index === 0 ? 1 : index === 1 ? 5 : index];
insert('maintenance_jobs', { id: index + 1, server: server.name, state: index === 1 ? 'failed' : 'done', target_revision: 4878,
requested_at: iso(now - (120 + index * 4) * MINUTE), finished_at: iso(now - (119 + index * 4) * MINUTE),
detail: index === 1 ? 'Synthetic update could not be verified.' : 'Synthetic scheduled restart completed.', kind: 'schedule', origin: 'manual',
provisioning_id: index === 0 ? 'fixture-operation-2' : null });
insert('watchdog_incidents', { id: `fixture-recovery-${index + 1}`, server: server.name, kind: 'health', state: 'resolved',
summary: 'Synthetic recovery finding resolved.', details: '{}', first_seen_at: iso(now - (130 + index * 4) * MINUTE),
last_seen_at: iso(now - (129 + index * 4) * MINUTE), finished_at: iso(now - (129 + index * 4) * MINUTE) });
insert('deployments', { id: `fixture-campaign-${index + 1}`, action: 'drift', started_at: iso(now - (140 + index * 4) * MINUTE),
finished_at: iso(now - (139 + index * 4) * MINUTE), triggered_by: 'Fixture operator' });
insert('deployment_servers', { deployment_id: `fixture-campaign-${index + 1}`, server: server.name, files_changed: 1, restart_state: 'done', players: 0 });
}
for (let index = 0; index < 2; index++) {
insert('betas', { id: index + 1, script_id: 1, script_version_id: 1, trigger_kind: 'ui', state: index ? 'rolled-back' : 'promoted',
opened_at: iso(now - (150 + index * 3) * MINUTE), closed_at: iso(now - (149 + index * 3) * MINUTE), notes: 'Synthetic completed beta.' });
insert('beta_scripts', { beta_id: index + 1, script_id: 1, script_version_id: 1, kind: 'script' });
insert('beta_servers', { beta_id: index + 1, server: fixtureServers[index].name, had_original: 0, stage: 'ready', updated_at: iso(now - (149 + index * 3) * MINUTE) });
}
insert('provisioning_sources', { operation_id: 'fixture-operation-2', kind: 'maintenance', source_id: '1', server: fixtureServers[1].name });
insert('provisioning_sources', { operation_id: 'fixture-operation-3', kind: 'autoscaling', source_id: 'fixture-paused-plan', server: fixtureServers[2].name });
})();
}
/** Refreshes existing synthetic observations only; never queues work or writes real files. */
export function refreshFixtureData(handle: Database.Database, now: number): void {
const marker = handle.prepare('SELECT value FROM settings WHERE key=?').get(FIXTURE_KEY) as { value: string } | undefined;
if (marker?.value !== FIXTURE_VERSION) throw new Error('Refusing to refresh an unmarked database.');
if (!Number.isFinite(now)) throw new Error('Invalid fixture time.');
handle.transaction(() => {
for (const [index, server] of fixtureServers.entries()) {
handle.prepare(`UPDATE servers SET last_seen_at=?,last_checked_at=?,container_checked_at=?,container_state=?,
last_players=?,last_map=?,last_roster=?,last_error=?,rcon_failures=?,last_reply_snippet=NULL WHERE name=?`)
.run(seenAt(server, now), iso(now), iso(now), containerState(server), server.players, server.map.canonical,
JSON.stringify(rosterFor(server, index)), server.state === 'stale' ? 'timeout: no reply from game server' : null,
server.state === 'stale' ? 2 : 0, server.name);
handle.prepare(`UPDATE server_telemetry SET received_at=?,payload=json_set(payload,'$.receivedAt',?) WHERE server=?`).run(iso(now), iso(now), server.name);
handle.prepare("UPDATE leaderboard_matches SET last_received_at=? WHERE server=? AND status='active' AND id BETWEEN 495 AND 500").run(iso(now), server.name);
handle.prepare('UPDATE plutonium_versions SET checked_at=? WHERE server=?').run(iso(now), server.name);
}
for (let index = 1; index <= 3; index++) {
const id = `fixture-host-${index}`;
handle.prepare("UPDATE ec_hosts SET last_heartbeat=?,inventory=json_set(inventory,'$.instanceStates',json(?)) WHERE id=?")
.run(iso(now - (index === 3 ? 10 * MINUTE : 0)), JSON.stringify(instanceStates(id, now)), id);
}
})();
}
+53
View File
@@ -0,0 +1,53 @@
import { mkdir, open, realpath, rm } from 'node:fs/promises';
import { basename, dirname, isAbsolute, join, resolve } from 'node:path';
export interface FixtureOptions { dbPath: string; now: number; watch: boolean }
export function parseFixtureOptions(args: string[], clock = Date.now()): FixtureOptions {
let dbPath = '', now = clock, watch = false, explicitTime = false;
const seen = new Set<string>();
for (let index = 0; index < args.length; index++) {
const option = args[index];
if (seen.has(option)) throw new Error(`Duplicate option: ${option}`);
seen.add(option);
if (option === '--watch') { watch = true; continue; }
if (option !== '--db' && option !== '--now') throw new Error(`Unknown option: ${option}`);
const value = args[++index];
if (!value || value.startsWith('--')) throw new Error(`Missing value for ${option}.`);
if (option === '--db') dbPath = value;
else {
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?(?:Z|[+-]\d{2}:\d{2})$/.test(value)
|| !Number.isFinite(Date.parse(value))) throw new Error('--now must be an ISO timestamp with a timezone.');
now = Date.parse(value); explicitTime = true;
}
}
if (!dbPath || !isAbsolute(dbPath)) throw new Error('Provide --db with an absolute path inside a new fixture directory. DB_PATH is never used.');
if (!Number.isFinite(now)) throw new Error('Invalid fixture clock.');
if (watch && explicitTime) throw new Error('Use --watch for live time or --now for a fixed snapshot, separately.');
return { dbPath: resolve(dbPath), now, watch };
}
/** An atomic new-directory reservation rejects existing DBs, sidecars and symlinks together. */
export async function reserveFixture(dbPath: string): Promise<{ root: string; dbPath: string }> {
if (!isAbsolute(dbPath)) throw new Error('Fixture database path must be absolute.');
const requested = resolve(dbPath);
const requestedRoot = dirname(requested);
// The destination's parent must already exist. Canonicalize /tmp on macOS,
// but never follow a pre-existing fixture directory or database symlink.
const parent = await realpath(dirname(requestedRoot));
const root = join(parent, basename(requestedRoot));
try { await mkdir(root, { mode: 0o700 }); }
catch (cause) {
if ((cause as NodeJS.ErrnoException).code === 'EEXIST') throw new Error('Fixture directory already exists. Choose a new directory; existing data is never overwritten.');
throw cause;
}
const target = join(root, basename(requested));
try {
const file = await open(target, 'wx', 0o600);
await file.close();
} catch (error) {
await rm(root, { recursive: true, force: true });
throw error;
}
return { root, dbPath: target };
}
+99
View File
@@ -0,0 +1,99 @@
import { randomBytes } from 'node:crypto';
import { createServer as createHttpServer } from 'node:http';
import { mkdir, rm, writeFile } from 'node:fs/promises';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { createServer, type ViteDevServer } from 'vite';
import type Database from 'better-sqlite3';
import { FIXTURE_KEY, FIXTURE_VERSION, fixtureServers, refreshFixtureData, seedFixtureData } from './fixture-data.js';
import { parseFixtureOptions, reserveFixture } from './fixture-options.js';
const help = `Create an isolated synthetic UI preview database.
npm run seed:fixture -- --db /tmp/ec-preview/cherry.db [--now 2026-10-07T12:00:00Z]
npm run seed:fixture -- --db /tmp/ec-live-preview/cherry.db --watch
The database's directory must not exist; its parent must exist. Existing files,
databases, symlinks and environment DB_PATH are never reused. --watch refreshes
only this new fixture's observations every 15 seconds until Ctrl+C. --now fixes
all sample times for reproducible snapshots and cannot be combined with --watch.
No web server, worker, game agent or network request is started by this command.
`;
async function main() {
const args = process.argv.slice(2);
if (args.length === 1 && ['--help', '-h'].includes(args[0])) { console.log(help); return; }
const options = parseFixtureOptions(args);
process.umask(0o077);
const destination = await reserveFixture(options.dbPath);
let vite: ViteDevServer | undefined;
let closeDb: (() => void) | undefined;
let complete = false;
try {
const paths = { DB_PATH: destination.dbPath, SERVERS_ROOT: join(destination.root, 't5'), T6_SERVERS_ROOT: join(destination.root, 't6'),
BETA_ARCHIVE_DIR: join(destination.root, 'beta-archive'), GAME_FILES_ROOT: join(destination.root, 'game-files'), SQLITE_TMPDIR: join(destination.root, 'sqlite-tmp') };
for (const [key, value] of Object.entries(paths)) {
process.env[key] = value;
if (key !== 'DB_PATH') await mkdir(value, { mode: 0o700 });
}
Object.assign(process.env, { RCON_PASSWORD: '', RCON_HOST: '127.0.0.1', EC_AUTH_ALLOW_HTTP: 'true',
DEPLOY_KEEP_DAYS: '7', DEPLOYMENT_RESTART_CONCURRENCY: '1', DEPLOYMENT_CREATE_CONCURRENCY: '1',
EC_SQLITE_MMAP_MB: '0', PLUTAINER_IMAGE: 'fixture.invalid/electric-cherry/preview:local' });
const repository = resolve(dirname(fileURLToPath(import.meta.url)), '..');
vite = await createServer({ root: repository, cacheDir: join(destination.root, 'vite-cache'), configFile: false, envFile: false, appType: 'custom',
// Vite 5 otherwise opens its HMR WebSocket port even with hmr:false.
// Attach it to an HTTP server that is deliberately never listened on.
server: { middlewareMode: true, hmr: { server: createHttpServer() }, watch: null }, optimizeDeps: { noDiscovery: true },
resolve: { alias: { $lib: join(repository, 'src/lib'), $ui: join(repository, 'src/lib/ui') } },
logLevel: 'error' });
// Load the real migrations through Vite's ?raw support. Importing hooks
// would start queues and agents; this script never loads the app router.
const database = await vite.ssrLoadModule('/src/lib/server/db/index.ts') as { db: () => Database.Database; closeDb: () => void };
closeDb = database.closeDb;
const handle = database.db();
const hosts = await vite.ssrLoadModule('/src/lib/server/hosts/schema.ts');
const auth = await vite.ssrLoadModule('/src/lib/server/auth/schema.ts');
const files = await vite.ssrLoadModule('/src/lib/server/game-files/schema.ts');
hosts.ensureHostSchema(handle); auth.ensureAuthSchema(handle); files.ensureGameFileSchema(handle);
seedFixtureData(handle, options.now);
const password = randomBytes(24).toString('base64url');
const { hashPassword } = await vite.ssrLoadModule('/src/lib/server/auth/passwords.js');
handle.prepare(`INSERT INTO auth_users(id,username,display_name,password_hash,role,created_at,updated_at)
VALUES('fixture-admin','demo','Demo operator',?,'admin',?,?)`).run(await hashPassword(password), options.now, options.now);
if ((handle.pragma('foreign_key_check') as unknown[]).length || handle.pragma('integrity_check', { simple: true }) !== 'ok') {
throw new Error('Fixture integrity validation failed.');
}
const environment = { ...paths, RCON_PASSWORD: '', RCON_HOST: '127.0.0.1', EC_AUTH_ALLOW_HTTP: 'true',
PLUTAINER_IMAGE: process.env.PLUTAINER_IMAGE! };
const shellQuote = (value: string) => `'${value.replaceAll("'", "'\\''")}'`;
await writeFile(join(destination.root, 'preview.env'), Object.entries(environment).map(([key, value]) => `export ${key}=${shellQuote(value)}`).join('\n') + '\n', { flag: 'wx', mode: 0o600 });
await writeFile(join(destination.root, 'fixture.json'), JSON.stringify({ format: FIXTURE_VERSION, marker: FIXTURE_KEY,
dbPath: destination.dbPath, createdAt: new Date(options.now).toISOString(), username: 'demo', password,
hosts: 3, servers: fixtureServers.map(server => server.name), matches: 500,
note: 'Synthetic local data only. Host credentials cannot authenticate; game images and library IDs are fictitious.' }, null, 2) + '\n', { flag: 'wx', mode: 0o600 });
handle.pragma('wal_checkpoint(TRUNCATE)');
complete = true;
console.log(`Created ${destination.dbPath}\n3 hosts · 12 servers · 500 matches · 7 days of metrics\nLocal sign-in details: ${join(destination.root, 'fixture.json')}\nPreview environment: ${join(destination.root, 'preview.env')}`);
if (options.watch) {
refreshFixtureData(handle, Date.now());
console.log('Refreshing synthetic observations every 15 seconds. Leave this terminal open; Ctrl+C stops the feeder.');
await new Promise<void>((resolvePromise, reject) => {
const finish = (error?: unknown) => { clearInterval(timer); process.off('SIGINT', stop); process.off('SIGTERM', stop); if (error) reject(error); else resolvePromise(); };
const stop = () => finish();
const timer = setInterval(() => { try { refreshFixtureData(handle, Date.now()); } catch (error) { finish(error); } }, 15_000);
process.on('SIGINT', stop); process.on('SIGTERM', stop);
});
}
} finally {
try { closeDb?.(); }
finally {
try { await vite?.close(); }
finally { if (!complete) await rm(destination.root, { recursive: true, force: true }); }
}
}
}
main().catch(error => {
console.error(`Fixture creation failed: ${error instanceof Error ? error.message : 'Unknown error'}`);
process.exitCode = 1;
});
+196
View File
@@ -0,0 +1,196 @@
import Database from 'better-sqlite3';
import { access, mkdtemp, readFile, realpath, rm, stat, symlink, writeFile } from 'node:fs/promises';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { parseFixtureOptions, reserveFixture } from '../../scripts/fixture-options.js';
import { FIXTURE_KEY, FIXTURE_VERSION, refreshFixtureData, seedFixtureData } from '../../scripts/fixture-data.js';
import { newFixture, type Fixture } from '../helpers/fixture.js';
const NOW = Date.parse('2026-10-07T12:00:00Z');
const run = promisify(execFile);
describe('fixture destination isolation', () => {
let root: string;
beforeEach(async () => { root = await mkdtemp(join(tmpdir(), 'cherry-fixture-safety-')); });
afterEach(async () => { vi.unstubAllEnvs(); await rm(root, { recursive: true, force: true }); });
it('requires an explicit absolute destination even with a production database in the environment', () => {
vi.stubEnv('DB_PATH', join(root, 'production.db'));
expect(() => parseFixtureOptions([], NOW)).toThrow(/--db/);
expect(() => parseFixtureOptions(['--db', './data/cherry.db'], NOW)).toThrow(/absolute/);
expect(parseFixtureOptions(['--db', join(root, 'preview', 'fixture.db')], NOW))
.toEqual({ dbPath: join(root, 'preview', 'fixture.db'), now: NOW, watch: false });
expect(() => parseFixtureOptions(['--db', join(root, 'preview', 'fixture.db'), '--force'], NOW)).toThrow(/Unknown option/);
});
it('keeps deterministic snapshot time separate from a live watch', () => {
const target = ['--db', join(root, 'preview', 'fixture.db')];
expect(parseFixtureOptions([...target, '--now', '2026-10-07T08:00:00-04:00'], 0).now).toBe(NOW);
expect(() => parseFixtureOptions([...target, '--now', '2026-10-07T12:00:00'], NOW)).toThrow(/timezone/);
expect(() => parseFixtureOptions([...target, '--now', '2026-10-07T12:00:00Z', '--watch'], NOW)).toThrow(/separately/);
expect(() => parseFixtureOptions([...target, '--db', join(root, 'second', 'fixture.db')], NOW)).toThrow(/Duplicate/);
});
it('reserves a private new directory and refuses reuse without touching the database or sidecars', async () => {
const reservation = await reserveFixture(join(root, 'preview', 'fixture.db'));
await writeFile(reservation.dbPath, 'retained database');
await writeFile(`${reservation.dbPath}-wal`, 'retained WAL');
await expect(reserveFixture(reservation.dbPath)).rejects.toThrow(/already exists/);
expect(await readFile(reservation.dbPath, 'utf8')).toBe('retained database');
expect(await readFile(`${reservation.dbPath}-wal`, 'utf8')).toBe('retained WAL');
expect((await stat(reservation.root)).mode & 0o777).toBe(0o700);
expect((await stat(reservation.dbPath)).mode & 0o777).toBe(0o600);
});
it('rejects a pre-existing destination-directory symlink instead of following it', async () => {
const target = await reserveFixture(join(root, 'production', 'database.db'));
await writeFile(target.dbPath, 'do not replace');
await symlink(target.root, join(root, 'preview'), 'dir');
await expect(reserveFixture(join(root, 'preview', 'database.db'))).rejects.toThrow(/already exists/);
expect(await readFile(target.dbPath, 'utf8')).toBe('do not replace');
});
it('runs the real CLI against only the explicit destination despite ambient production paths', async () => {
const ambient = join(root, 'production.db');
await writeFile(ambient, 'original production sentinel');
const destination = join(root, 'preview', 'fixture.db');
const { stdout } = await run(process.execPath, ['--import', 'tsx', 'scripts/seed-fixture.ts', '--db', destination, '--now', '2026-10-07T12:00:00Z'], {
cwd: process.cwd(), timeout: 30_000, maxBuffer: 200_000,
env: { ...process.env, DB_PATH: ambient, SERVERS_ROOT: join(root, 'production-t5'),
T6_SERVERS_ROOT: join(root, 'production-t6'), BETA_ARCHIVE_DIR: join(root, 'production-archive'),
GAME_FILES_ROOT: join(root, 'production-files'), RCON_PASSWORD: 'ambient-secret-must-not-escape' }
});
expect(await readFile(ambient, 'utf8')).toBe('original production sentinel');
await expect(access(join(root, 'production-t5'))).rejects.toThrow();
await expect(access(join(root, 'production-files'))).rejects.toThrow();
expect(stdout).not.toContain('ambient-secret-must-not-escape');
expect(stdout).not.toContain('[worker] started');
const saved = JSON.parse(await readFile(join(root, 'preview', 'fixture.json'), 'utf8'));
expect(saved.dbPath).toBe(await realpath(destination));
expect(saved.password.length).toBeGreaterThanOrEqual(24);
const environment = await readFile(join(root, 'preview', 'preview.env'), 'utf8');
expect(environment).not.toContain('production');
expect(environment).not.toContain('ambient-secret');
const seeded = new Database(destination, { readonly: true, fileMustExist: true });
try {
expect(seeded.pragma('integrity_check', { simple: true })).toBe('ok');
expect(seeded.pragma('foreign_key_check')).toEqual([]);
expect(seeded.prepare('SELECT COUNT(*) n FROM leaderboard_matches').get()).toEqual({ n: 500 });
expect(seeded.prepare("SELECT COUNT(*) n FROM auth_users WHERE username='demo' AND role='admin'").get()).toEqual({ n: 1 });
expect(seeded.prepare("SELECT COUNT(*) n FROM ec_host_operations WHERE state IN ('pending','running','accepted')").get()).toEqual({ n: 0 });
} finally { seeded.close(); }
}, 35_000);
it('cleans up only its newly owned directory when fixture creation cannot complete', async () => {
const retained = join(root, 'keep.txt'); await writeFile(retained, 'keep');
// This filename collides with the generated environment file. Whether
// rejected up front or at write time, no partial preview should remain.
await expect(run(process.execPath, ['--import', 'tsx', 'scripts/seed-fixture.ts', '--db', join(root, 'failed-preview', 'preview.env')], {
cwd: process.cwd(), timeout: 30_000, maxBuffer: 200_000
})).rejects.toThrow();
await expect(access(join(root, 'failed-preview'))).rejects.toThrow();
expect(await readFile(retained, 'utf8')).toBe('keep');
}, 35_000);
});
describe('useful synthetic fixture data', () => {
let fixture: Fixture;
let handle: Database.Database;
beforeAll(async () => {
vi.resetModules(); fixture = await newFixture([]);
const { db } = await import('../../src/lib/server/db/index.js');
const { ensureHostSchema } = await import('../../src/lib/server/hosts/schema.js');
const { ensureAuthSchema } = await import('../../src/lib/server/auth/schema.js');
const { ensureGameFileSchema } = await import('../../src/lib/server/game-files/schema.js');
handle = db(); ensureHostSchema(handle); ensureAuthSchema(handle); ensureGameFileSchema(handle);
seedFixtureData(handle, NOW);
}, 20_000);
beforeEach(() => handle.exec('SAVEPOINT fixture_test'));
afterEach(() => handle.exec('ROLLBACK TO fixture_test; RELEASE fixture_test'));
afterAll(async () => { await fixture?.cleanup(); });
const count = (sql: string) => (handle.prepare(sql).get() as { n: number }).n;
it('provides complete paginated match, fleet and metrics evidence with valid relationships', () => {
expect(handle.pragma('foreign_key_check')).toEqual([]);
expect(count('SELECT COUNT(*) n FROM servers')).toBe(12);
expect(count('SELECT COUNT(*) n FROM ec_hosts')).toBe(3);
expect(count('SELECT COUNT(*) n FROM leaderboard_matches')).toBe(500);
expect(count("SELECT COUNT(*) n FROM leaderboard_matches WHERE status='active'")).toBe(6);
expect(count("SELECT COUNT(*) n FROM leaderboard_matches WHERE status='completed'")).toBeGreaterThan(400);
expect(handle.prepare('SELECT DISTINCT start_players FROM leaderboard_matches WHERE start_players IS NOT NULL ORDER BY start_players').all())
.toEqual([1, 2, 3, 4].map(start_players => ({ start_players })));
expect(count('SELECT COUNT(*) n FROM leaderboard_matches WHERE start_players IS NULL')).toBeGreaterThan(0);
expect(count('SELECT COUNT(DISTINCT guid) n FROM leaderboard_match_players')).toBeGreaterThan(50);
const metrics = handle.prepare('SELECT COUNT(DISTINCT at) samples,MIN(at) oldest,MAX(at) newest FROM metric_samples').get() as { samples: number; oldest: number; newest: number };
expect(metrics.samples).toBe(7 * 24 * 60);
expect(metrics.newest).toBe(NOW);
expect(metrics.newest - metrics.oldest).toBe((metrics.samples - 1) * 60_000);
expect(count('SELECT COUNT(*) n FROM metric_samples WHERE players<0 OR players>capacity')).toBe(0);
});
it('supports actual leaderboard pagination and evidence for every exact squad size', async () => {
const { getLeaderboard } = await import('../../src/lib/server/fleet/leaderboard.js');
const first = getLeaderboard({ game: 't5', map: 'theater', players: 2, limit: 25 });
const second = getLeaderboard({ game: 't5', map: 'theater', players: 2, limit: 25, offset: 25 });
expect(first.total).toBeGreaterThan(50);
expect(first.entries).toHaveLength(25);
expect(second.entries).toHaveLength(25);
expect(new Set([...first.entries, ...second.entries].map(entry => entry.guid)).size).toBe(50);
for (const players of [1, 2, 3, 4] as const) {
const board = getLeaderboard({ players });
expect(board.total).toBeGreaterThan(0);
const evidence = board.entries[0].records.highestRound;
expect(handle.prepare('SELECT start_players FROM leaderboard_matches WHERE id=?').get(evidence.matchId)).toEqual({ start_players: players });
}
});
it('never seeds executable work or usable host credentials and pauses automated lifecycle policies', async () => {
expect(count("SELECT COUNT(*) n FROM server_deployments WHERE state IN ('queued','waiting','running','awaiting-health')")).toBe(0);
expect(count("SELECT COUNT(*) n FROM ec_host_operations WHERE state IN ('pending','accepted','running','unknown')")).toBe(0);
expect(count("SELECT COUNT(*) n FROM maintenance_jobs WHERE state IN ('queued','running','waiting')")).toBe(0);
expect(count("SELECT COUNT(*) n FROM beta_servers bs JOIN betas b ON b.id=bs.beta_id WHERE b.state IN ('running','ready-promote')")).toBe(0);
expect(count("SELECT COUNT(*) n FROM deployment_servers WHERE restart_state IN ('pending','failed','held')")).toBe(0);
const { maintenancePolicies } = await import('../../src/lib/server/maintenance/policies.js');
expect(maintenancePolicies()).toEqual({ paused: true, health: false, drift: false, updates: false });
for (const row of handle.prepare('SELECT token_hash FROM ec_host_credentials').all() as { token_hash: string }[])
expect(row.token_hash).not.toMatch(/^[0-9a-f]{64}$/);
expect(() => seedFixtureData(handle, NOW)).toThrow(/new database/);
expect(count('SELECT COUNT(*) n FROM leaderboard_matches')).toBe(500);
});
it('refuses watch updates without the exact ownership marker', () => {
const before = handle.prepare('SELECT name,last_checked_at FROM servers ORDER BY name').all();
handle.prepare('DELETE FROM settings WHERE key=?').run(FIXTURE_KEY);
expect(() => refreshFixtureData(handle, NOW + 60_000)).toThrow(/unmarked/);
handle.prepare('INSERT INTO settings(key,value) VALUES(?,?)').run(FIXTURE_KEY, 'some-other-fixture');
expect(() => refreshFixtureData(handle, NOW + 60_000)).toThrow(/unmarked/);
expect(handle.prepare('SELECT name,last_checked_at FROM servers ORDER BY name').all()).toEqual(before);
});
it('refreshes only owned observations while preserving unrelated resources and retained history', () => {
handle.prepare('INSERT INTO servers(name,last_checked_at) VALUES(?,?)').run('unowned-server', '2020-01-01');
handle.prepare('INSERT INTO ec_hosts(id,name,created_at,last_heartbeat) VALUES(?,?,?,?)').run('unowned-host', 'Keep this host', '2020-01-01', '2020-01-01');
const history = handle.prepare("SELECT id,last_received_at FROM leaderboard_matches WHERE status!='active' ORDER BY id").all();
const operations = handle.prepare('SELECT id,state,updated_at FROM ec_host_operations ORDER BY id').all();
refreshFixtureData(handle, NOW + 60_000);
expect(handle.prepare('SELECT value FROM settings WHERE key=?').get(FIXTURE_KEY)).toEqual({ value: FIXTURE_VERSION });
expect(handle.prepare('SELECT last_checked_at FROM servers WHERE name=?').get('unowned-server')).toEqual({ last_checked_at: '2020-01-01' });
expect(handle.prepare('SELECT last_heartbeat FROM ec_hosts WHERE id=?').get('unowned-host')).toEqual({ last_heartbeat: '2020-01-01' });
expect(handle.prepare("SELECT id,last_received_at FROM leaderboard_matches WHERE status!='active' ORDER BY id").all()).toEqual(history);
expect(handle.prepare('SELECT id,state,updated_at FROM ec_host_operations ORDER BY id').all()).toEqual(operations);
expect(count(`SELECT COUNT(*) n FROM servers WHERE last_checked_at='${new Date(NOW + 60_000).toISOString()}'`)).toBe(12);
});
it('restores synthetic observations after monitoring without making the offline host live', () => {
const original = handle.prepare('SELECT name,container_state,last_error,rcon_failures,last_players,last_roster FROM servers ORDER BY name').all();
handle.prepare("UPDATE servers SET container_state='unknown',last_error='monitor timeout',rcon_failures=2,last_players=NULL,last_roster=NULL").run();
refreshFixtureData(handle, NOW + 60_000);
expect(handle.prepare('SELECT name,container_state,last_error,rcon_failures,last_players,last_roster FROM servers ORDER BY name').all()).toEqual(original);
const hosts = handle.prepare('SELECT last_heartbeat FROM ec_hosts').all() as { last_heartbeat: string }[];
expect(hosts.filter(host => NOW + 60_000 - Date.parse(host.last_heartbeat) > 90_000)).toHaveLength(1);
expect(handle.pragma('foreign_key_check')).toEqual([]);
});
});