#!/usr/bin/env node // Run the macOS native client with a real server or the local fake server. // A real-server run remains unverified until a person checks the in-game flows. import fs from 'node:fs'; import path from 'node:path'; import net from 'node:net'; import { spawn, execFile } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { redact, secretLiterals } from './redact_stream.mjs'; const repo = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const defaults = { binary: path.join(repo, 'build-release/native_render/mt_native_render'), client: path.join(repo, 'Client'), output: path.join(repo, 'build/native-acceptance', new Date().toISOString().replaceAll(':', '-')), }; function usage() { console.log(`Usage: node script/native_mac_acceptance.mjs (--server HOST:AUTH_PORT:GAME_PORT | --fake) [options] --binary FILE Native live-client binary (default: build-release/native_render/mt_native_render) --client DIR 40250 Client directory (default: Client) --output DIR New evidence directory (default: build/native-acceptance/) --min-seconds N Minimum real-server observation time (default: 1800) --frames N Fake-server smoke frames (default: 180; fake mode only) --help Show this help Real-server mode opens the login screen. Enter credentials in the app, exercise the checklist in the report, then close the window. Credentials are never passed on the command line. The report requires manual review before PASS.`); } function optionsOf(argv) { const options = { ...defaults, minSeconds: 1800, frames: 180 }; for (let i = 0; i < argv.length; i += 1) { const key = argv[i]; if (key === '--help') { options.help = true; continue; } if (!['--server', '--fake', '--binary', '--client', '--output', '--min-seconds', '--frames'].includes(key)) throw new Error(`Unknown option: ${key}`); if (key === '--fake') { options.fake = true; continue; } if (++i >= argv.length) throw new Error(`Missing value for ${key}`); const field = { '--server': 'server', '--binary': 'binary', '--client': 'client', '--output': 'output', '--min-seconds': 'minSeconds', '--frames': 'frames' }[key]; options[field] = argv[i]; } if (!options.help) { if (Boolean(options.server) === Boolean(options.fake)) throw new Error('Choose exactly one of --server or --fake'); for (const field of ['minSeconds', 'frames']) { options[field] = Number(options[field]); if (!Number.isInteger(options[field]) || options[field] < (field === 'frames' ? 1 : 0)) throw new Error(`Invalid ${field}`); } if (options.server && argv.includes('--frames')) throw new Error('--frames is only for fake-server smoke'); } return options; } function parseServer(spec) { const match = /^([^:\s]+):([0-9]+):([0-9]+)$/.exec(spec); if (!match || [match[2], match[3]].some((value) => +value < 1 || +value > 65535)) throw new Error('Server must be HOST:AUTH_PORT:GAME_PORT (one shared host)'); return { host: match[1], authPort: +match[2], gamePort: +match[3] }; } function reachable(host, port) { return new Promise((resolve) => { const socket = net.createConnection({ host, port }); socket.setTimeout(3000); socket.once('connect', () => { socket.destroy(); resolve(true); }); socket.once('error', () => { socket.destroy(); resolve(false); }); socket.once('timeout', () => { socket.destroy(); resolve(false); }); }); } function readRss(pid) { return new Promise((resolve) => execFile('ps', ['-o', 'stat=,rss=', '-p', String(pid)], { timeout: 3000 }, (error, stdout) => { if (error) return resolve(null); const [state, value] = stdout.trim().split(/\s+/); const rss = Number(value); resolve(state?.startsWith('Z') || !Number.isInteger(rss) || rss <= 0 ? null : rss); })); } function parseSummary(log) { const line = log.trim().split('\n').reverse().find((row) => row.startsWith('device=') && row.includes(' frames=')); if (!line) return null; const values = {}; for (const match of line.matchAll(/(?:^| )([a-z0-9_]+)=([^ ]+)/g)) { const number = Number(match[2]); values[match[1]] = Number.isFinite(number) ? number : match[2]; } return values; } async function main() { let options; try { options = optionsOf(process.argv.slice(2)); } catch (error) { console.error(error.message); usage(); return 2; } if (options.help) { usage(); return 0; } if (process.platform !== 'darwin') { console.error('This acceptance runner targets macOS'); return 2; } if (!fs.existsSync(options.binary) || !fs.existsSync(path.join(path.dirname(options.binary), 'python27.zip')) || !fs.existsSync(path.join(options.client, 'pack/Index'))) { console.error('Missing native live-client binary, adjacent python27.zip, or Client/pack/Index'); return 2; } if (fs.existsSync(options.output)) { console.error(`Output already exists: ${options.output}`); return 2; } let server = null; if (options.server) { try { server = parseServer(options.server); } catch (error) { console.error(error.message); return 2; } for (const port of [server.authPort, server.gamePort]) { if (!await reachable(server.host, port)) { console.error(`Server endpoint unreachable: ${server.host}:${port}; client not started`); return 2; } } } fs.mkdirSync(options.output, { recursive: true, mode: 0o700 }); const logPath = path.join(options.output, 'client.log'); const rssPath = path.join(options.output, 'rss.jsonl'); const logFile = fs.openSync(logPath, 'w', 0o600); const rssFile = fs.openSync(rssPath, 'w', 0o600); const args = ['--live-client', options.client]; if (server) args.push('--live-server', options.server, '--login-screen'); else args.push('--fake-mobs', '24', '--frames', String(options.frames)); const started = Date.now(); const child = spawn(options.binary, args, { cwd: repo, stdio: ['inherit', 'pipe', 'pipe'] }); const literals = secretLiterals(); let log = ''; let pending = ''; const append = (chunk) => { pending += chunk.toString('utf8'); const lines = pending.split('\n'); pending = lines.pop(); for (const line of lines) { const clean = `${redact(line, literals)}\n`; fs.writeSync(logFile, clean); process.stdout.write(clean); log += clean; } }; child.stdout.on('data', append); child.stderr.on('data', append); let sampleBusy = false; let pendingSample = Promise.resolve(); const samples = []; const sample = async () => { if (sampleBusy) return; sampleBusy = true; const rss = await readRss(child.pid); if (rss !== null) { const row = { wall_ms: Date.now(), rss_kib: rss }; samples.push(row); fs.writeSync(rssFile, `${JSON.stringify(row)}\n`); } sampleBusy = false; }; await sample(); const interval = setInterval(() => { pendingSample = sample(); }, 1000); const onSignal = (signal) => child.kill(signal); process.on('SIGINT', onSignal); process.on('SIGTERM', onSignal); const result = await new Promise((resolve) => { child.once('error', (error) => resolve({ code: null, signal: null, error: error.message })); child.once('close', (code, signal) => resolve({ code, signal })); }); clearInterval(interval); await pendingSample; process.off('SIGINT', onSignal); process.off('SIGTERM', onSignal); if (pending) append('\n'); fs.closeSync(logFile); fs.closeSync(rssFile); const durationSeconds = Math.round((Date.now() - started) / 1000); const summary = parseSummary(log); const completed = result.code === 0 && summary?.frames > 0; const status = !completed ? 'FAIL' : server ? 'NEEDS_MANUAL_REVIEW' : 'SMOKE_PASS'; const report = { status, mode: server ? 'real_server' : 'fake_server', started_at: new Date(started).toISOString(), duration_seconds: durationSeconds, minimum_seconds: server ? options.minSeconds : null, minimum_met: server ? durationSeconds >= options.minSeconds : null, exit: result, summary, rss: { samples: samples.length, first_kib: samples[0]?.rss_kib ?? null, last_kib: samples.at(-1)?.rss_kib ?? null, max_kib: samples.length ? Math.max(...samples.map((row) => row.rss_kib)) : null }, manual_checks: server ? { login: null, character_selection: null, movement: null, combat: null, map_change: null, inventory_and_chat: null, resize_and_focus: null, visual_comparison: null, } : null, note: server ? 'Review manual checks and frame/RSS trends; this runner does not certify gameplay or memory leaks.' : 'Fake-server smoke checks startup and rendering only.', }; const reportPath = path.join(options.output, 'report.json'); fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`, { mode: 0o600 }); console.log(`Native acceptance: ${status}; report=${reportPath}`); return completed ? 0 : 1; } main().then((code) => { process.exitCode = code; }).catch((error) => { console.error(error.message); process.exitCode = 1; });