From 5b3e8cb1a37c400f3c95a99fc20726b8fa8e2114 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Wed, 7 Oct 2026 20:08:02 -0400 Subject: [PATCH] Restore reproducible isolated UI fixtures --- docs/development/README.md | 48 +++++++ package.json | 2 +- scripts/fixture-data.ts | 235 ++++++++++++++++++++++++++++++++ scripts/fixture-options.ts | 53 +++++++ scripts/seed-fixture.ts | 99 ++++++++++++++ tests/unit/seed-fixture.test.ts | 196 ++++++++++++++++++++++++++ 6 files changed, 632 insertions(+), 1 deletion(-) create mode 100644 scripts/fixture-data.ts create mode 100644 scripts/fixture-options.ts create mode 100644 scripts/seed-fixture.ts create mode 100644 tests/unit/seed-fixture.test.ts diff --git a/docs/development/README.md b/docs/development/README.md index fba68c8..8413aed 100644 --- a/docs/development/README.md +++ b/docs/development/README.md @@ -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 diff --git a/package.json b/package.json index 50fc9d4..5212efd 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/fixture-data.ts b/scripts/fixture-data.ts new file mode 100644 index 0000000..21d9f84 --- /dev/null +++ b/scripts/fixture-data.ts @@ -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) => { + 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); + } + })(); +} diff --git a/scripts/fixture-options.ts b/scripts/fixture-options.ts new file mode 100644 index 0000000..3805a85 --- /dev/null +++ b/scripts/fixture-options.ts @@ -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(); + 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 }; +} diff --git a/scripts/seed-fixture.ts b/scripts/seed-fixture.ts new file mode 100644 index 0000000..e40b4b3 --- /dev/null +++ b/scripts/seed-fixture.ts @@ -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((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; +}); diff --git a/tests/unit/seed-fixture.test.ts b/tests/unit/seed-fixture.test.ts new file mode 100644 index 0000000..7b176b7 --- /dev/null +++ b/tests/unit/seed-fixture.test.ts @@ -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([]); + }); +});