Restore reproducible isolated UI fixtures
This commit is contained in:
@@ -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;
|
||||
});
|
||||
Reference in New Issue
Block a user