diff --git a/package.json b/package.json index 00596cc..920e87f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "msms", "version": "0.3.0", - "description": "Minecraft Server Management System — portable, bilingual (EN/TR) desktop control panel for Minecraft servers.", + "description": "Minecraft Server Management System \u2014 portable, bilingual (EN/TR) desktop control panel for Minecraft servers.", "author": "CaYatur", "license": "MIT", "main": "./out/main/index.js", @@ -14,6 +14,8 @@ "typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false", "typecheck": "npm run typecheck:node && npm run typecheck:web", "lint": "eslint . --ext .ts,.tsx || echo lint-skipped", + "gates": "node scripts/gates.mjs", + "gates:packaged": "node scripts/gates.mjs --packaged", "dist": "electron-vite build && electron-builder", "dist:portable": "electron-vite build && electron-builder --win portable", "dist:dir": "electron-vite build && electron-builder --dir" diff --git a/scripts/gates.mjs b/scripts/gates.mjs new file mode 100644 index 0000000..e53ec2f --- /dev/null +++ b/scripts/gates.mjs @@ -0,0 +1,93 @@ +/** + * Run every smoke gate, against the dev build or against a packaged binary. + * + * The packaged half is the point (#166). Gates had only ever been run with + * `npx electron .`, so nothing looked at the thing an operator downloads — + * and `MSMS_SMOKE_WORLDS` had been failing against every packaged build since + * at least v0.2.5 without anyone knowing, because a packaged app is built for + * the Windows GUI subsystem and its console output goes nowhere. + * + * node scripts/gates.mjs the dev build + * node scripts/gates.mjs --packaged the newest portable exe in release/ + * node scripts/gates.mjs --packaged a specific binary + * + * The verdict is the exit code, per this project's doctrine. The transcript of + * a failing gate is printed from `msms-data/logs/smoke.log`, which is where a + * packaged run writes it. + */ +import { spawnSync } from 'node:child_process' +import { existsSync, readdirSync, readFileSync, rmSync } from 'node:fs' +import { join, resolve } from 'node:path' + +const GATES = [ + 'MSMS_SMOKE', + 'MSMS_SMOKE_WORLDS', + 'MSMS_SMOKE_WEB', + 'MSMS_SMOKE_MODUPDATE', + 'MSMS_SMOKE_ANALYSIS', + 'MSMS_SMOKE_AUDIT', + 'MSMS_SMOKE_ALERTS', + 'MSMS_SMOKE_BRIDGE', + 'MSMS_SMOKE_EVENTS', + 'MSMS_SMOKE_METRICS', + 'MSMS_SMOKE_JAVA' +] + +const root = process.cwd() +const args = process.argv.slice(2) +const packaged = args.includes('--packaged') + +function newestPortable() { + const dir = join(root, 'release') + if (!existsSync(dir)) return null + const hits = readdirSync(dir) + .filter((n) => n.endsWith('-portable.exe')) + .map((n) => join(dir, n)) + if (!hits.length) return null + // Newest by name, which sorts by version for this project's naming. + return hits.sort()[hits.length - 1] +} + +const explicit = args.find((a) => a !== '--packaged') +const exe = packaged ? (explicit ? resolve(explicit) : newestPortable()) : null +if (packaged && !exe) { + console.error('No packaged binary found. Build one with `npm run dist:portable` first.') + process.exit(2) +} +if (packaged && !existsSync(exe)) { + console.error('No such binary: ' + exe) + process.exit(2) +} + +// The gates want the pre-seeded fixture, the same one the dev run uses. +const baseDir = join(root, 'dev-root') +const transcript = join(baseDir, 'msms-data', 'logs', 'smoke.log') + +console.log(packaged ? 'Gates against ' + exe : 'Gates against the dev build') +console.log('') + +let failed = 0 +for (const gate of GATES) { + try { + rmSync(transcript, { force: true }) + } catch { + /* no transcript yet */ + } + const env = { ...process.env, [gate]: '1', MSMS_BASE_DIR: baseDir } + const r = packaged + ? spawnSync(exe, [], { env, stdio: 'ignore' }) + : spawnSync('npx', ['electron', '.'], { env, stdio: 'ignore', shell: true }) + const code = r.status ?? 1 + const ok = code === 0 + if (!ok) failed++ + console.log((ok ? 'PASS ' : 'FAIL ') + gate + (ok ? '' : ' (exit ' + code + ')')) + if (!ok && existsSync(transcript)) { + // The reason, from the file — a packaged run has no console to have printed it. + const lines = readFileSync(transcript, 'utf-8').split('\n').filter((l) => l.includes('FAIL')) + for (const l of lines.slice(-3)) console.log(' ' + l.trim()) + } +} + +console.log('') +console.log(failed ? failed + ' gate(s) failed' : 'all ' + GATES.length + ' gates passed') +process.exit(failed ? 1 : 0) diff --git a/src/main/index.ts b/src/main/index.ts index 55a8916..7d734fe 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -16,6 +16,7 @@ import { initBlockColours } from './core/clientAssets' import { resolveBaseDir } from './paths' import { log } from './logger' import { startTileWarming } from './core/tileWarm' +import { teeSmokeOutput } from './smokeLog' import { runSmoke, runWizardSmoke, @@ -230,7 +231,9 @@ if (!gotLock) { // worse than a failing test, because it looks like a working one. if (Object.keys(process.env).some((k) => k.startsWith('MSMS_SMOKE'))) { // eslint-disable-next-line no-console - console.log('SMOKE: FAIL - another instance holds the single-instance lock; nothing ran') + // Through the logger as well: a packaged app has no console, so a bare + // console.log here would make this the one failure nobody can see (#166). + log.error('SMOKE: FAIL - another instance holds the single-instance lock; nothing ran') app.exit(1) } else { app.quit() @@ -241,6 +244,10 @@ if (!gotLock) { app.whenReady().then(async () => { Menu.setApplicationMenu(null) loadConfig() + // A packaged app has no console attached, so without this a gate can only + // ever report its exit code and nothing else (#166). Here rather than at + // module scope, which is where the logger itself is known to work. + if (Object.keys(process.env).some((k) => k.startsWith('MSMS_SMOKE'))) teeSmokeOutput() log.info(`MSMS starting. Base dir: ${resolveBaseDir()}`) handleImageProtocol() registerIpc() diff --git a/src/main/smoke.ts b/src/main/smoke.ts index 05a0979..0c6c9d8 100644 --- a/src/main/smoke.ts +++ b/src/main/smoke.ts @@ -417,6 +417,63 @@ function waitFor(pred: () => boolean, ms: number): Promise { * synthetic timestamps, then checks filtering, ordering, counts, retention, * per-server isolation and cleanup. */ +/** + * The repository, when the gates are running inside one (#166). + * + * A few checks assert things about the CODE rather than about a running + * process — that every declared IPC channel has a handler, that the bundled + * bridge jar matches the plugin.yml it was built from, that every route in the + * router appears in the documented surface. Those can only be answered by + * reading the source, and a PACKAGED app has no source: it is an asar of built + * JavaScript, extracted to a temp folder that contains no `src` at all. + * + * `MSMS_SMOKE_WORLDS` had been failing against every packaged build since at + * least v0.2.5 for exactly this reason — an unhandled ENOENT on + * `src/shared/ipc.ts` that took the whole gate down after it had already + * passed everything it could actually test. + * + * Returns null when there is no source tree, and every caller then SAYS SO + * rather than passing quietly: a check that silently does nothing is the + * failure mode this project keeps finding. + */ +function sourceRoot(): string | null { + const root = process.cwd() + if (existsSync(join(root, 'src', 'shared', 'ipc.ts'))) return root + // Looks like the repository, but the file this probes for is gone. That is a + // MOVED FILE, not a packaged run, and answering "no source tree" to it would + // silently switch off every source-derived check in every gate — the exact + // way a guard against a missing file rots into a guard against running at all. + if (existsSync(join(root, 'electron.vite.config.ts'))) { + // eslint-disable-next-line no-console + console.log( + 'SMOKE: FAIL - this is the repository but src/shared/ipc.ts is missing, so the ' + + 'source-tree probe would disable every source-derived check' + ) + app.exit(1) + } + return null +} + +/** + * Named so a skip is legible in the transcript rather than an absence. + * + * And it REFUSES to skip when there is a source tree. The danger in "skip this + * when the file is missing" is that it quietly becomes "skip this always" — the + * check stops running in the one place it can run, and nothing says so. A + * developer run has the sources, so reaching here with them present is a bug in + * the guard rather than a packaged run, and it fails the gate. + */ +function skipNoSource(gate: string, what: string): void { + if (sourceRoot()) { + // eslint-disable-next-line no-console + console.log(`${gate}: FAIL - ${what} was skipped inside the source tree; the guard is wrong`) + app.exit(1) + return + } + // eslint-disable-next-line no-console + console.log(`${gate}: SKIP - ${what} needs the source tree, and this is a packaged run`) +} + export async function runEventsSmoke(): Promise { const fail = (m: string): void => { console.log('EVENTS-SMOKE: FAIL -', m) @@ -1582,11 +1639,15 @@ export async function runModUpdateSmoke(): Promise { if (!bridgeVersionOf(bundled.name)) return fail('the bundled jar is not named like one: ' + bundled.name) // ...and it is the jar the sources describe. A committed build output can // drift from the code it was built from, and nothing else would notice. - const declared = /^version:\s*(.+)$/m.exec( - readFileSync(join(process.cwd(), 'bridge', 'src', 'main', 'resources', 'plugin.yml'), 'utf-8') - )?.[1]?.trim() - if (declared !== bundled.version) { - return fail('the bundled jar is ' + bundled.version + ' but plugin.yml declares ' + declared) + if (!sourceRoot()) { + skipNoSource('MODUPDATE-SMOKE', 'the bundled jar vs plugin.yml check') + } else { + const declared = /^version:\s*(.+)$/m.exec( + readFileSync(join(sourceRoot() as string, 'bridge', 'src', 'main', 'resources', 'plugin.yml'), 'utf-8') + )?.[1]?.trim() + if (declared !== bundled.version) { + return fail('the bundled jar is ' + bundled.version + ' but plugin.yml declares ' + declared) + } } if (!bridgeInstallMod.bundledBridgeSha256()) return fail('the bundled jar could not be hashed') @@ -1654,13 +1715,18 @@ export async function runBridgeSmoke(): Promise { return fail('a tick behind the plugin logger prefix did not parse') } // Tied to the real sources rather than to a literal: the marker the parser - // looks for, and the name Paper prints in front of it. - const pluginName = /^name:\s*(.+)$/m - .exec(readFileSync(join(process.cwd(), 'bridge', 'src', 'main', 'resources', 'plugin.yml'), 'utf-8'))?.[1] - ?.trim() - if (!pluginName) return fail('plugin.yml has no name') - if (BRIDGE_MARKER === '[' + pluginName + ']') { - return fail('the marker is identical to the plugin name — the logger prefix would be sliced instead') + // looks for, and the name Paper prints in front of it. Source-derived, so + // it can only run inside the repository (#166). + if (!sourceRoot()) { + skipNoSource('BRIDGE-SMOKE', 'the marker vs plugin.yml name check') + } else { + const pluginName = /^name:\s*(.+)$/m + .exec(readFileSync(join(sourceRoot() as string, 'bridge', 'src', 'main', 'resources', 'plugin.yml'), 'utf-8'))?.[1] + ?.trim() + if (!pluginName) return fail('plugin.yml has no name') + if (BRIDGE_MARKER === '[' + pluginName + ']') { + return fail('the marker is identical to the plugin name — the logger prefix would be sliced instead') + } } console.log('BRIDGE-SMOKE: marker parsed at column 0, behind [INFO]:, [STDOUT] and the plugin logger prefix') @@ -2363,10 +2429,13 @@ export async function runWorldsSmoke(): Promise { // Read out of the SOURCE rather than from ipcMain, because the smoke // registers handlers itself and asking the live process would only prove // that this run wired them up. - { - const ipcSrc = readFileSync(join(process.cwd(), 'src', 'shared', 'ipc.ts'), 'utf-8') - const regSrc = readFileSync(join(process.cwd(), 'src', 'main', 'ipc', 'register.ts'), 'utf-8') - const preSrc = readFileSync(join(process.cwd(), 'src', 'preload', 'index.ts'), 'utf-8') + if (!sourceRoot()) { + skipNoSource('WORLDS-SMOKE', 'the IPC channel/handler cross-check') + } else { + const src = sourceRoot() as string + const ipcSrc = readFileSync(join(src, 'src', 'shared', 'ipc.ts'), 'utf-8') + const regSrc = readFileSync(join(src, 'src', 'main', 'ipc', 'register.ts'), 'utf-8') + const preSrc = readFileSync(join(src, 'src', 'preload', 'index.ts'), 'utf-8') // `name: 'channel:string',` inside the channel table. const declared = [...ipcSrc.matchAll(/^\s{2}(\w+):\s*'([a-z0-9:-]+)',?$/gim)].map((m) => m[1]) @@ -8615,56 +8684,61 @@ export async function runWebSmoke(): Promise { * which is the failure that actually happens. So the route literals are * read out of `handlePanel` itself and each one must appear in the table. */ - const srcPath = join(process.cwd(), 'src', 'main', 'web', 'server.ts') - if (!existsSync(srcPath)) return fail('cannot read the router source at ' + srcPath) - const whole = readFileSync(srcPath, 'utf-8') - const from = whole.indexOf('async function handlePanel') - // The NEXT top-level function, not `startWebServer`. That marker held only - // while `handlePanel` happened to be the last thing before it; #146 put - // `handleMapPage` in between, and its routes — which belong to a different - // listener and are deliberately not part of the `/api/v1` surface — were - // then read as undocumented panel routes. - const after = whole.slice(from + 1) - const next = after.search(/\n(?:export )?(?:async )?function /) - const to = next < 0 ? whole.indexOf('export function startWebServer') : from + 1 + next - if (from < 0 || to < 0 || to < from) return fail('could not isolate handlePanel in the source') - const router = whole.slice(from, to) - // The isolation is load-bearing: too short and the coverage check reads a - // handful of routes and passes, which looks exactly like success. - if (!router.includes('/api/keys') || router.length < 20000) { - return fail('handlePanel was isolated to ' + router.length + ' chars — the slice is wrong') - } - - // `/api/…` literals, mapped onto the versioned form the table uses. - for (const m of router.matchAll(/\b(?:raw)?[Pp]ath === '(\/api\/[^']*)'/g)) { - const lit = m[1] - const want = lit.startsWith('/api/v1') ? lit : API_PREFIX + lit.slice(4) - if (!documented.includes(want)) { - return fail('the router serves ' + lit + ' and the spec does not document it') - } - } - - // Sub-paths chosen by string comparison: `sub`, `action`, `rest`. - // These are covered by a wildcard segment in the table (the eight - // moderation actions, the four world actions) rather than by a path each. - const wildcardCovered = new Set([...MODERATION_ACTIONS, ...WORLD_ACTIONS, 'delete']) - const segments = new Set() - for (const p of documented) for (const seg of p.split('/')) if (seg && !seg.startsWith('{')) segments.add(seg) - for (const m of router.matchAll(/\b(?:sub|action|rest) === '([^']+)'/g)) { - const token = m[1] - if (!token || wildcardCovered.has(token)) continue - // `rest` carries multi-segment values like `admin/category/delete`. - if (token.split('/').every((seg) => segments.has(seg))) continue - return fail('the router handles "' + token + '" and the spec documents no such path') - } - - // ...and the other direction: nothing in the table may be invented. - for (const p of documented) { - const literals = p.slice(API_PREFIX.length).split('/').filter((s) => s && !s.startsWith('{')) - const leaf = literals[literals.length - 1] - if (!leaf) continue - if (!router.includes("'" + leaf) && !router.includes('/' + leaf)) { - return fail('the spec documents ' + p + ' and the router has no such route') + // Source-derived, so it can only run inside the repository (#166). + if (!sourceRoot()) { + skipNoSource('WEB-SMOKE', 'the router vs documented-surface cross-check') + } else { + const srcPath = join(process.cwd(), 'src', 'main', 'web', 'server.ts') + if (!existsSync(srcPath)) return fail('cannot read the router source at ' + srcPath) + const whole = readFileSync(srcPath, 'utf-8') + const from = whole.indexOf('async function handlePanel') + // The NEXT top-level function, not `startWebServer`. That marker held only + // while `handlePanel` happened to be the last thing before it; #146 put + // `handleMapPage` in between, and its routes — which belong to a different + // listener and are deliberately not part of the `/api/v1` surface — were + // then read as undocumented panel routes. + const after = whole.slice(from + 1) + const next = after.search(/\n(?:export )?(?:async )?function /) + const to = next < 0 ? whole.indexOf('export function startWebServer') : from + 1 + next + if (from < 0 || to < 0 || to < from) return fail('could not isolate handlePanel in the source') + const router = whole.slice(from, to) + // The isolation is load-bearing: too short and the coverage check reads a + // handful of routes and passes, which looks exactly like success. + if (!router.includes('/api/keys') || router.length < 20000) { + return fail('handlePanel was isolated to ' + router.length + ' chars — the slice is wrong') + } + + // `/api/…` literals, mapped onto the versioned form the table uses. + for (const m of router.matchAll(/\b(?:raw)?[Pp]ath === '(\/api\/[^']*)'/g)) { + const lit = m[1] + const want = lit.startsWith('/api/v1') ? lit : API_PREFIX + lit.slice(4) + if (!documented.includes(want)) { + return fail('the router serves ' + lit + ' and the spec does not document it') + } + } + + // Sub-paths chosen by string comparison: `sub`, `action`, `rest`. + // These are covered by a wildcard segment in the table (the eight + // moderation actions, the four world actions) rather than by a path each. + const wildcardCovered = new Set([...MODERATION_ACTIONS, ...WORLD_ACTIONS, 'delete']) + const segments = new Set() + for (const p of documented) for (const seg of p.split('/')) if (seg && !seg.startsWith('{')) segments.add(seg) + for (const m of router.matchAll(/\b(?:sub|action|rest) === '([^']+)'/g)) { + const token = m[1] + if (!token || wildcardCovered.has(token)) continue + // `rest` carries multi-segment values like `admin/category/delete`. + if (token.split('/').every((seg) => segments.has(seg))) continue + return fail('the router handles "' + token + '" and the spec documents no such path') + } + + // ...and the other direction: nothing in the table may be invented. + for (const p of documented) { + const literals = p.slice(API_PREFIX.length).split('/').filter((s) => s && !s.startsWith('{')) + const leaf = literals[literals.length - 1] + if (!leaf) continue + if (!router.includes("'" + leaf) && !router.includes('/' + leaf)) { + return fail('the spec documents ' + p + ' and the router has no such route') + } } } @@ -8709,7 +8783,19 @@ export async function runWebSmoke(): Promise { // Keep the checked-in copy current. It is a generated artefact, and a // stale one in the repo is worse than none — an integrator reads the file // in the repository, not the one this process would serve. - writeFileSync(join(process.cwd(), 'docs', 'openapi.json'), JSON.stringify(doc, null, 2) + '\n', 'utf-8') + // + // There is no repository in a packaged run and `docs/` is not shipped, so + // this WROTE INTO A DIRECTORY THAT DOES NOT EXIST and took the gate down + // with an ENOENT (#166). + if (!sourceRoot()) { + skipNoSource('WEB-SMOKE', 'refreshing the checked-in openapi.json') + } else { + writeFileSync( + join(sourceRoot() as string, 'docs', 'openapi.json'), + JSON.stringify(doc, null, 2) + '\n', + 'utf-8' + ) + } console.log( 'WEB-SMOKE: api docs OK (' + documented.length + ' documented paths, router-derived coverage both ways, no install data)' diff --git a/src/main/smokeLog.ts b/src/main/smokeLog.ts new file mode 100644 index 0000000..f133d5f --- /dev/null +++ b/src/main/smokeLog.ts @@ -0,0 +1,75 @@ +/** + * A smoke run's transcript, written somewhere it can be read afterwards (#166). + * + * The gates report by writing to the console and exiting with a code. That is + * fine for `npx electron .`, and useless against the thing an operator actually + * downloads: a packaged app is built for the Windows GUI subsystem, so it has + * no console attached and every `console.log` goes nowhere. A packaged gate + * could only ever say "1" — no assertion name, no context, nothing to act on. + * + * `MSMS_SMOKE_WORLDS` had been failing against the packaged build since at + * least v0.2.5 and nobody could see why, because nobody could see anything. + * + * So the transcript goes to a file as well. Truncated per run, because the + * question is always "what did THIS run say". + */ +import { appendFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { logsDir } from './paths' +import { log } from './logger' + +let installed = false + +export function smokeLogPath(): string { + return join(logsDir(), 'smoke.log') +} + +/** + * Mirror console output into `smoke.log`. + * + * Wraps rather than replaces: the dev run still prints to the terminal exactly + * as it did, and the packaged run gains a readable record. `logger.ts` writes + * through `console` too, so the file ends up holding the gate's own lines + * interleaved with everything the app logged while it ran — which is the + * context you want when a gate fails somewhere unexpected. + */ +export function teeSmokeOutput(): void { + if (installed) return + installed = true + let path: string + try { + path = smokeLogPath() + writeFileSync(path, `[${new Date().toISOString()}] smoke transcript\n`) + } catch (err) { + // No transcript is not a reason to stop the run — the exit code is still + // the signal — but it must not be SILENT. A tee that quietly did nothing is + // how a packaged gate stayed unreadable in the first place. + log.warn('Smoke transcript could not be opened: ' + String((err as Error)?.message ?? err)) + return + } + const levels = ['log', 'info', 'warn', 'error'] as const + for (const level of levels) { + /* eslint-disable @typescript-eslint/no-explicit-any */ + const original = (console as any)[level].bind(console) + ;(console as any)[level] = (...args: unknown[]): void => { + original(...args) + try { + appendFileSync( + path, + args.map((a) => (typeof a === 'string' ? a : safe(a))).join(' ') + '\n' + ) + } catch { + /* a transcript that cannot be written must not fail the run */ + } + } + } +} + +function safe(v: unknown): string { + try { + if (v instanceof Error) return `${v.name}: ${v.message}\n${v.stack ?? ''}` + return JSON.stringify(v) + } catch { + return String(v) + } +}