From bc6e62cd9ab256f8dc8e1a6d83bfdd01f9b0dad4 Mon Sep 17 00:00:00 2001 From: CaYatur Date: Thu, 6 Aug 2026 03:15:53 +0300 Subject: [PATCH] Run the gates against the thing people download MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #166. MSMS_SMOKE_WORLDS had been failing against every packaged build since at least v0.2.5 — the released v0.2.5 binary fails it too — and nobody knew, for two compounding reasons. **Nothing could be read.** 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. The transcript now goes to `msms-data/logs/smoke.log` as well, truncated per run. That alone turned an opaque exit code into the actual defect in one run, and it is why the rest of this commit exists. **Four gates read the repository at runtime.** They assert things about the CODE rather than about a running process — every declared IPC channel has a handler, the bundled bridge jar matches the plugin.yml it was built from, every route in the router appears in the documented surface, the checked-in openapi.json is current. None of that exists in a packaged app: it is an asar of built JavaScript, extracted to a temp folder with no `src` in it. So they threw ENOENT and took their whole gate down, after having already passed everything they could genuinely test. They now skip when there is no source tree, and SAY SO in the transcript. The guard is itself guarded, because "skip when the file is missing" is one careless edit away from "skip always", and a check that silently stops running is what this project keeps finding: - `skipNoSource` refuses to skip when a source tree IS present, and fails the gate instead. That catches an inverted or unconditional guard. - `sourceRoot` fails the gate when it is standing in the repository and the file it probes for has moved, rather than reporting "packaged" and disabling every source-derived check at once. Both proved failable: renaming the probe target fails a dev run with "this is the repository but src/shared/ipc.ts is missing, so the source-tree probe would disable every source-derived check". `npm run gates` and `npm run gates:packaged` run all eleven, so this is a release step rather than something done by hand once. dev build 11/11 pass packaged binary 11/11 pass (was 2 failing: WORLDS and WEB) --- package.json | 4 +- scripts/gates.mjs | 93 ++++++++++++++++++ src/main/index.ts | 9 +- src/main/smoke.ts | 220 ++++++++++++++++++++++++++++++------------- src/main/smokeLog.ts | 75 +++++++++++++++ 5 files changed, 332 insertions(+), 69 deletions(-) create mode 100644 scripts/gates.mjs create mode 100644 src/main/smokeLog.ts 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) + } +}