import yargs from 'yargs/yargs' import { hideBin } from 'yargs/helpers' import { pathToFileURL } from 'url' import { fileURLToPath } from 'url' import { dirname, join, resolve } from 'path' import { RedisConnexion } from '../redisConnexion.js' import { configHelper } from '../configHelper.js' import { MySQLClient } from '@p42/p42modules' const __dirname = dirname(fileURLToPath(import.meta.url)) const __filename = fileURLToPath(import.meta.url) const UUID_DASHED_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i const UUID_HEX32_RE = /^[0-9a-f]{32}$/i export function normalizeUuid(value) { if(typeof(value) !== 'string') { throw(new Error('UUID must be a string')) } const trimmed = value.trim() if(!trimmed) throw(new Error('UUID must be a non-empty string')) if(UUID_DASHED_RE.test(trimmed)) { return(trimmed.toLowerCase()) } let hex = trimmed if(/^0x/i.test(hex)) hex = hex.slice(2) if(!UUID_HEX32_RE.test(hex)) { throw(new Error( `Invalid UUID: ${value} (expected dashed form or 32-char hex, optionally prefixed with 0x)` )) } hex = hex.toLowerCase() return(`${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`) } const LOG_COLORS = { action: '\x1b[37m', success: '\x1b[32m', error: '\x1b[31m', reset: '\x1b[0m', } function createTestMesh(handlers) { return({ dispatchMessage(_cnx, msg, chan) { for(const handler of handlers) handler(msg, chan) }, }) } function buildBaseParser() { return(yargs(hideBin(process.argv)) .usage('$0 [options]') .command('$0 ', 'Run a GodDaemons test module', y => y .positional('module', { describe: 'Test module name (e.g. maestro1)', type: 'string', }) ) .options({ config: { alias: 'c', describe: 'Path to config.json', default: join(__dirname, '..', 'config.json'), type: 'string', }, guiDatabase: { describe: 'Override mysql.guiDatabase for this run', type: 'string', }, simDatabase: { describe: 'Override mysql.simDatabase for this run', type: 'string', }, }) .help() .version(false) ) } function meshRedisConn(mesh, meshName, rootConfig, meshModule) { const { redis, ...meshConfig } = mesh return(redis.map(cfg => new RedisConnexion({ debug: false, config: { ...cfg, ...meshConfig, ...rootConfig }, redisId: cfg.redisId, meshName, meshModule, }) )) } async function loadTestModule(moduleName) { const modulePath = join(__dirname, 'modules', `${moduleName}.js`) try { return(await import(pathToFileURL(modulePath).href)) } catch(err) { if(err.code === 'ERR_MODULE_NOT_FOUND') { throw(new Error(`Test module not found: ${moduleName} (expected ${modulePath})`)) } throw(err) } } async function main() { const preArgv = buildBaseParser().parseSync() const moduleName = preArgv.module ?? preArgv._[0] if(!moduleName) { buildBaseParser().showHelp() process.exit(1) } const testModule = await loadTestModule(moduleName) let parser = buildBaseParser() if(typeof(testModule.configureYargs) === 'function') { parser = testModule.configureYargs(parser) } const argv = parser.parseSync() const resolvedModule = argv.module ?? argv._[0] const cfgh = new configHelper({ localfile: argv.config }) await cfgh.fetchConfigFile() const config = cfgh.config if(argv.guiDatabase) config.mysql.guiDatabase = argv.guiDatabase if(argv.simDatabase) config.mysql.simDatabase = argv.simDatabase const arenaHandlers = new Set() const testMesh = createTestMesh(arenaHandlers) const systemConns = meshRedisConn(config.systemMesh, 'system', config, null) const arenaConns = meshRedisConn(config.arenaMesh, 'arena', config, testMesh) const systemCnx = systemConns.find(c => c.redisConfig.role === 'primary') ?? systemConns[0] const arenaCnx = arenaConns.find(c => c.redisConfig.role === 'primary') ?? arenaConns[0] const log = (type, message) => { const color = LOG_COLORS[type] ?? LOG_COLORS.action console.log(`${color}${message}${LOG_COLORS.reset}`) } log('action', `Loading test module: ${resolvedModule}`) log('action', `Config: ${argv.config}`) const databases = MySQLClient.resolveDatabases(config.mysql) if(argv.guiDatabase || argv.simDatabase) { log('action', `MySQL databases: gui=${databases.guiDatabase}, sim=${databases.simDatabase}`) } log('action', 'Connecting to Redis (system)...') await systemCnx.redisLogin() log('success', `System Redis connected (${systemCnx.redisConfig.host}:${systemCnx.redisConfig.port})`) log('action', 'Connecting to Redis (arena)...') await arenaCnx.redisLogin() await arenaCnx.redisChansStart() log('success', `Arena Redis connected (${arenaCnx.redisConfig.host}:${arenaCnx.redisConfig.port})`) log('action', 'Connecting to MySQL...') const db = await MySQLClient.createPool(config.mysql) log('success', 'MySQL pool ready') const ctx = { argv, config, databases, db, systemCnx, arenaCnx, log, normalizeUuid, onArenaMessage(handler) { arenaHandlers.add(handler) }, offArenaMessage(handler) { arenaHandlers.delete(handler) }, } let exitCode = 0 try { if(typeof(testModule.run) !== 'function') { throw(new Error(`Test module ${resolvedModule} must export a run(ctx) function`)) } await testModule.run(ctx) log('success', `Test module ${resolvedModule} finished`) } catch(err) { log('error', err.message ?? String(err)) exitCode = 1 } finally { await db.end() await systemCnx.redisClient.quit() await arenaCnx.redisClient.quit() if(arenaCnx.redisSubscriber) await arenaCnx.redisSubscriber.quit() } process.exit(exitCode) } const isMain = process.argv[1] && resolve(__filename) === resolve(process.argv[1]) if(isMain) { main().catch(err => { console.error(`${LOG_COLORS.error}${err.message ?? err}${LOG_COLORS.reset}`) process.exit(1) }) }