explodorama / files / tools / report-replay.mjs
report-replay.mjs
351348d1 2026-11-04 Pavel Safronov | 1 | #!/usr/bin/env node |
| 2 | // Turns a bug report into a replay URL (Q103): node tools/report-replay.mjs <mail.eml | report.txt | record.json> | |
| 3 | // Finds the "Run record: {...}" line (the mail's quoted-printable text is decoded first), or a bare | |
| 4 | // record, writes replays/<name>.json (git-ignored; served by `npm run dev` as /__replay/<name>.json) | |
| 5 | // and prints the addresses of the pages that play it (the page has two players, red and blue): | |
| 6 | // ?replay=<name> the report's last turns (red) against today's autopilot (blue); | |
| 7 | // ?replay=<name>&rules=<commit> the whole run played by the rules of that commit (red) and by today's (blue). | |
| 8 | // The commit is the one the report says it was made on (or `--rules <commit>`); its `src/sim` is | |
| 9 | // taken out of git into rules-versions/<commit>/ (git-ignored) for the page to load. | |
| 10 | import { execFileSync } from 'node:child_process' | |
| 11 | import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' | |
| 12 | import { basename } from 'node:path' | |
| 13 | import { fileURLToPath } from 'node:url' | |
| 14 | ||
| 15 | const args = process.argv.slice(2) | |
| 16 | const flag = args.indexOf('--rules') | |
| 17 | const wanted = flag >= 0 ? args.splice(flag, 2)[1] : null | |
| 18 | const file = args[0] | |
| 19 | if (!file) { | |
| 20 | console.error('usage: node tools/report-replay.mjs <bug report mail or text file>') | |
| 21 | process.exit(1) | |
| 22 | } | |
| 23 | const raw = readFileSync(file, 'latin1') | |
| 24 | ||
| 25 | /** Quoted-printable to text: soft line breaks removed, =XX bytes read as UTF-8. */ | |
| 26 | function decodeQp(text) { | |
| 27 | const joined = text.replace(/=\r?\n/g, '') | |
| 28 | const bytes = [] | |
| 29 | for (let i = 0; i < joined.length; i++) { | |
| 30 | if (joined[i] === '=' && /^[0-9A-Fa-f]{2}$/.test(joined.slice(i + 1, i + 3))) { | |
| 31 | bytes.push(parseInt(joined.slice(i + 1, i + 3), 16)) | |
| 32 | i += 2 | |
| 33 | } else bytes.push(...Buffer.from(joined[i], 'latin1')) | |
| 34 | } | |
| 35 | return Buffer.from(bytes).toString('utf8') | |
| 36 | } | |
| 37 | ||
| 38 | const text = /quoted-printable/i.test(raw) ? decodeQp(raw) : raw | |
| 39 | const found = text.match(/Run record: (\{.*\})\s*$/m) | |
| 40 | let record | |
| 41 | try { | |
| 42 | record = JSON.parse(found ? found[1] : text.trim()) | |
| 43 | } catch { | |
| 44 | console.error('No run record found in', file) | |
| 45 | process.exit(1) | |
| 46 | } | |
| 47 | // The record comes from a mail: nothing in it may end up in a path unchecked. | |
| 48 | if (!Array.isArray(record.actions) || !Number.isInteger(record.seed) || !Number.isInteger(record.simVersion)) { | |
| 49 | console.error('That is not a run record (it needs an integer seed and simVersion, and actions).') | |
| 50 | process.exit(1) | |
| 51 | } | |
| 52 | const name = `seed${record.seed}-v${record.simVersion}-${record.actions.length}` | |
| 53 | mkdirSync(new URL('../replays/', import.meta.url), { recursive: true }) | |
| 54 | writeFileSync(new URL(`../replays/${name}.json`, import.meta.url), JSON.stringify(record)) | |
| 55 | console.log(`Record: ${record.actions.length} actions, seed ${record.seed}, rules ${record.simVersion} (from ${basename(file)})`) | |
| 56 | console.log(`Replay (with \`npm run dev\` running): http://localhost:5173/?replay=${name}`) | |
| 57 | ||
| 58 | // The rules the report was made on: its "commit abc1234" line, or --rules. | |
| 59 | const commit = wanted ?? text.match(/commit ([0-9a-f]{7,40})/)?.[1] | |
| 60 | if (commit && !/^[0-9a-f]{7,40}$/.test(commit)) { | |
| 61 | console.error('--rules takes a commit hash (7 to 40 hex digits), not a name like a tag or HEAD~1.') | |
| 62 | process.exit(1) | |
| 63 | } | |
| 64 | if (commit) { | |
| 65 | const root = fileURLToPath(new URL('..', import.meta.url)) | |
| 66 | const dir = new URL(`../rules-versions/${commit}/`, import.meta.url) | |
| 67 | try { | |
| 68 | const full = execFileSync('git', ['rev-parse', '--verify', `${commit}^{commit}`], { cwd: root, encoding: 'utf8' }).trim() | |
| 69 | if (!existsSync(new URL('src/sim/turn.ts', dir))) { | |
| 70 | mkdirSync(dir, { recursive: true }) | |
| 71 | const tar = execFileSync('git', ['archive', full, 'src/sim'], { cwd: root, maxBuffer: 1 << 28 }) | |
| 72 | execFileSync('tar', ['-x', '-C', fileURLToPath(dir)], { input: tar }) | |
| 73 | } | |
| 74 | console.log(`Rules of ${commit} against today's, the whole run: http://localhost:5173/?replay=${name}&rules=${commit}`) | |
| 75 | } catch { | |
| 76 | console.log(`(No rules page: commit ${commit} is not in this repository.)`) | |
| 77 | } | |
| 78 | } |