fuzzyworld

explodorama / files / tools / report-replay.mjs

report-replay.mjs

Blame: the commit that last changed each line · file · history

351348d1 2026-11-04 Pavel Safronov1#!/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.
10import { execFileSync } from 'node:child_process'
11import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
12import { basename } from 'node:path'
13import { fileURLToPath } from 'node:url'
14
15const args = process.argv.slice(2)
16const flag = args.indexOf('--rules')
17const wanted = flag >= 0 ? args.splice(flag, 2)[1] : null
18const file = args[0]
19if (!file) {
20 console.error('usage: node tools/report-replay.mjs <bug report mail or text file>')
21 process.exit(1)
22}
23const raw = readFileSync(file, 'latin1')
24
25/** Quoted-printable to text: soft line breaks removed, =XX bytes read as UTF-8. */
26function 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
38const text = /quoted-printable/i.test(raw) ? decodeQp(raw) : raw
39const found = text.match(/Run record: (\{.*\})\s*$/m)
40let record
41try {
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.
48if (!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}
52const name = `seed${record.seed}-v${record.simVersion}-${record.actions.length}`
53mkdirSync(new URL('../replays/', import.meta.url), { recursive: true })
54writeFileSync(new URL(`../replays/${name}.json`, import.meta.url), JSON.stringify(record))
55console.log(`Record: ${record.actions.length} actions, seed ${record.seed}, rules ${record.simVersion} (from ${basename(file)})`)
56console.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.
59const commit = wanted ?? text.match(/commit ([0-9a-f]{7,40})/)?.[1]
60if (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}
64if (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}