/** * The always-on CI-on-main Discord report. * * One message leads with the commit SHA + e2e pass/fail + the runtime-perf table * (perf-report.ts), then screenshot changes are attached as triptychs * (old | new+boxes | heatmap), ADDED images, and a REMOVED list. Because perf is * always present, the comment fires on every main commit even with no screenshot * change — "see it in hindsight on every commit." * * Safety / robustness: * - Posts only on push to main (unless --force); a missing DISCORD_WEBHOOK_URL is * a silent no-op, so it's inert on PRs / forks (which can't see the secret). * - Attachments batched ≤10/message and downscaled to fit Discord's size caps; * 429 Retry-After honoured; first-run flood (hundreds of ADDED) collapsed. * - --dry-run composes everything and prints it without POSTing (how it's tested). * * CLI (from tests/): * tsx tools/screenshots/post-discord.ts [--dry-run] [--force] [--e2e pass|fail] [--subject S] */ import * as fs from 'fs'; import * as path from 'path'; import { execFileSync } from 'child_process'; import { DIFF_OUT_DIR, splitKey } from './config'; import { loadPng, encodeWithinCap } from './image-ops'; import { buildPerfReport, fetchPreviousPerf } from './perf-report'; import type { Report } from './compare'; const MAX_FILE_BYTES = 8 * 1024 * 1024; const MAX_MSG_BYTES = 24 * 1024 * 1024; const MAX_FILES_PER_MSG = 9; // ≤9 images per Discord message; the rest spill into more messages const MAX_CONTENT = 1000; // split message text into multiple messages beyond this many chars const MAX_TOTAL_FILES = 30; // ~4 messages of images; excess is summarized, not posted const FLOOD_N = 12; // more ADDED than this ⇒ show a few exemplars, not all const REMOVED_CAP = 40; // cap the REMOVED name list in the message body type Attachment = { name: string; buffer: Buffer }; type Message = { content: string; files: Attachment[] }; function sanitize(name: string): string { return name.replace(/[^\w.-]+/g, '_'); } function e2eBadge(status: string | undefined): string { if (status === 'pass') return '✅ e2e passed'; if (status === 'fail') return '❌ e2e failed'; return 'ℹ️ e2e status unknown'; } function commitSubject(sha: string | undefined): string { if (!sha) return ''; try { return execFileSync('git', ['log', '-1', '--pretty=%s', sha], { encoding: 'utf8' }).trim(); } catch { return ''; } } function readReport(root: string): Report | null { const p = path.join(root, DIFF_OUT_DIR, 'report.json'); if (!fs.existsSync(p)) return null; try { return JSON.parse(fs.readFileSync(p, 'utf8')) as Report; } catch { return null; } } /** Encode a PNG path to a within-cap attachment, or null if missing/unreadable. */ function attach(root: string, rel: string, name: string): Attachment | null { const abs = path.isAbsolute(rel) ? rel : path.join(root, rel); if (!fs.existsSync(abs)) return null; try { return { name: sanitize(name), buffer: encodeWithinCap(loadPng(abs), MAX_FILE_BYTES) }; } catch { return null; } } /** One status line for the 3D-renderer regression suite (tests/3d-regression), * from the compare-dirs report.json of each level that ran. Empty when the * suite didn't run (reports absent) so the message stays inert for builds * without the 3D harness. Triptychs live in the run artifact, not Discord. */ function threeDStatusLine(): string[] { const levels: Array<{ tag: string; dir: string }> = [ { tag: 'parity', dir: '3d-regression/output/diff/parity' }, { tag: 'webgl-self', dir: '3d-regression/output/diff/webgl-self' }, ]; const parts: string[] = []; let drift = false; for (const { tag, dir } of levels) { const file = path.join(dir, 'report.json'); if (!fs.existsSync(file)) continue; try { const r = JSON.parse(fs.readFileSync(file, 'utf8')); const changed = (r.changed?.length ?? 0) + (r.added?.length ?? 0) + (r.removed?.length ?? 0); const total = (r.unchangedCount ?? 0) + (r.changed?.length ?? 0); if (changed === 0) { parts.push(`${tag} ${total}/${total}`); } else { drift = true; const worst = r.changed?.[0]; const worstNote = worst ? ` (worst \`${worst.name}\` ${(worst.changedRatio * 100).toFixed(1)}%)` : ''; parts.push(`**${tag} ${changed} changed**${worstNote}`); } } catch { /* unreadable report — skip the level */ } } if (!parts.length) return []; return [`${drift ? '⚠️' : '✅'} 3D renderer: ${parts.join(' · ')}${drift ? ' — triptychs in the run artifact' : ''}`]; } /** Build the header text: SHA, subject, e2e, perf table, screenshot summary, removed list. */ function buildHeader(report: Report | null, perfBlock: string, meta: { sha?: string; subject: string; e2e?: string }): string { const shortSha = meta.sha ? meta.sha.slice(0, 7) : 'local'; const lines = [`📸 **CI screenshot + perf report** · \`${shortSha}\``]; if (meta.subject) lines.push(`> ${meta.subject}`); lines.push(e2eBadge(meta.e2e)); if (perfBlock) lines.push('', perfBlock); // Per-engine breakdown suffix, e.g. " (2 chromium / 1 firefox)"; single-engine // sets show just the engine name so the origin is always visible. const byEngine = (entries: Array<{ name: string }>): string => { if (!entries.length) return ''; const counts = new Map(); for (const e of entries) { const eng = splitKey(e.name).engine; counts.set(eng, (counts.get(eng) ?? 0) + 1); } if (counts.size < 2) return ` (${[...counts.keys()][0]})`; return ` (${[...counts.entries()].map(([e, n]) => `${n} ${e}`).join(' / ')})`; }; lines.push(''); if (!report) { // No report.json = the compare gate never ran (baseline fetch skipped or // incomplete). Saying "no drift" here would make a disabled pipeline // indistinguishable from a healthy one. lines.push('⚠️ screenshot gate SKIPPED — no baseline comparison ran (R2 fetch skipped/failed; check CI_SCREENSHOTS_S3_* secrets and the fetch step log)'); } else if (!report.changed.length && !report.added.length && !report.removed.length) { lines.push('✅ no screenshot drift'); } else if (report.driftLikely) { lines.push(`⚠️ **${report.changed.length} screenshots changed broadly** — looks like host env drift → re-promote in morelli (https://pcbjam-morelli-staging.pcbjam-staging.workers.dev)`); } else { lines.push( `⚠️ **screenshot drift**: ${report.changed.length} changed${byEngine(report.changed)}, ` + `${report.added.length} added${byEngine(report.added)}, ` + `${report.removed.length} removed${byEngine(report.removed)}` ); } if (report?.removed.length) { const shown = report.removed.slice(0, REMOVED_CAP).map((r) => `\`${r.name}\``).join(', '); const extra = report.removed.length > REMOVED_CAP ? ` (+${report.removed.length - REMOVED_CAP} more)` : ''; lines.push('➖ REMOVED: ' + shown + extra); } lines.push(...threeDStatusLine()); return lines.join('\n'); } /** Collect the image attachments (changed triptychs + added images) with flood-collapse + total cap. */ export function buildAttachments(root: string, report: Report | null): { files: Attachment[]; notes: string[] } { const files: Attachment[] = []; const notes: string[] = []; if (!report) return { files, notes }; for (const c of report.changed) { if (files.length >= MAX_TOTAL_FILES) break; const a = attach(root, c.triptych, `CHANGED_${c.name}`); if (a) files.push(a); } const addedToShow = report.added.length > FLOOD_N ? report.added.slice(0, 3) : report.added; if (report.added.length > FLOOD_N) { notes.push(`➕ ${report.added.length} added (showing ${addedToShow.length})`); } for (const ad of addedToShow) { if (files.length >= MAX_TOTAL_FILES) break; const a = attach(root, ad.image, `ADDED_${ad.name}`); if (a) files.push(a); } const removedToShow = report.removed.length > FLOOD_N ? report.removed.slice(0, 3) : report.removed; if (report.removed.length > FLOOD_N) { notes.push(`➖ ${report.removed.length} removed (showing ${removedToShow.length})`); } for (const rm of removedToShow) { if (files.length >= MAX_TOTAL_FILES) break; const a = attach(root, rm.image, `REMOVED_${rm.name}`); if (a) files.push(a); } const shown = files.length; const wanted = report.changed.length + addedToShow.length + removedToShow.length; if (wanted > shown) notes.push(`(${wanted - shown} more images omitted — see the CI artifact)`); return { files, notes }; } /** * Assemble Discord messages: first the text content split into ≤MAX_CONTENT-char * messages (at line boundaries, never inside a ``` fence so the perf table stays * intact), then the image attachments in messages of ≤MAX_FILES_PER_MSG files. */ export function paginate(header: string, files: Attachment[]): Message[] { const messages: Message[] = []; // 1) Text content → one or more text-only messages. let cur = ''; let inFence = false; for (const line of header.split('\n')) { const next = cur ? cur + '\n' + line : line; if (!inFence && cur && next.length > MAX_CONTENT) { messages.push({ content: cur, files: [] }); cur = line; } else { cur = next; } if (line.trim().startsWith('```')) inFence = !inFence; } if (cur) messages.push({ content: cur, files: [] }); // 2) Image attachments → messages of ≤MAX_FILES_PER_MSG files and ≤MAX_MSG_BYTES. let batch: Attachment[] = []; let bytes = 0; const flush = () => { if (batch.length) messages.push({ content: '', files: batch }); batch = []; bytes = 0; }; for (const f of files) { if (batch.length >= MAX_FILES_PER_MSG || bytes + f.buffer.length > MAX_MSG_BYTES) flush(); batch.push(f); bytes += f.buffer.length; } flush(); return messages.length ? messages : [{ content: header, files: [] }]; } async function sleep(ms: number): Promise { return new Promise((r) => setTimeout(r, ms)); } export async function postMessage(webhook: string, msg: Message): Promise { for (let attemptNo = 0; attemptNo < 6; attemptNo++) { const form = new FormData(); form.append('payload_json', JSON.stringify({ content: msg.content || '', allowed_mentions: { parse: [] } })); msg.files.forEach((f, i) => form.append(`files[${i}]`, new Blob([new Uint8Array(f.buffer)], { type: 'image/png' }), f.name)); const res = await fetch(webhook, { method: 'POST', body: form }); if (res.ok) return; if (res.status === 429) { const retryAfter = Number(res.headers.get('retry-after')) || 2; await sleep((retryAfter + 0.5) * 1000); continue; } throw new Error(`Discord POST failed ${res.status}: ${(await res.text()).slice(0, 300)}`); } throw new Error('Discord POST failed after retries (429)'); } function parseArgs(argv: string[]): Record { const out: Record = {}; for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (a === '--dry-run') out.dryRun = true; else if (a === '--force') out.force = true; else if (a === '--e2e') out.e2e = argv[++i]; else if (a === '--subject') out.subject = argv[++i]; else if (a === '--repo') out.repo = argv[++i]; } return out; } async function main(): Promise { const args = parseArgs(process.argv.slice(2)); const root = process.cwd(); const isMainPush = process.env.GITHUB_REF === 'refs/heads/main' && process.env.GITHUB_EVENT_NAME === 'push'; if (!args.force && !args.dryRun && !isMainPush) { console.log('[discord] not a push to main — skipping'); return; } const webhook = process.env.DISCORD_WEBHOOK_URL; if (!webhook && !args.dryRun) { console.log('[discord] DISCORD_WEBHOOK_URL unset — skipping (inert on PRs/forks)'); return; } const sha = process.env.GITHUB_SHA; const report = readReport(root); // Loud pipeline alert: if the run produced ZERO screenshots (every baseline shows as // "removed"), that's almost always a build/pipeline failure — not a real mass removal. // Post a distinct, image-less alert instead of dumping a giant REMOVED list. if (report && report.changed.length + report.added.length + report.unchangedCount === 0) { const shortSha = sha ? sha.slice(0, 7) : 'local'; const runUrl = process.env.GITHUB_SERVER_URL && process.env.GITHUB_REPOSITORY && process.env.GITHUB_RUN_ID ? `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}` : ''; const alert = `⚠️ **No screenshots produced this run** · \`${shortSha}\` — compare saw 0 rendered ` + `vs ${report.removed.length} baselines. Likely a build/pipeline failure, not a mass removal.` + (runUrl ? `\n${runUrl}` : ''); if (args.dryRun) { console.log('--- message 1/1 (0 files) ---\n' + alert); } else { await postMessage(webhook!, { content: alert, files: [] }); console.log('[discord] posted pipeline alert (0 screenshots rendered)'); } return; } const prevDir = fetchPreviousPerf((args.repo as string) || process.env.GITHUB_REPOSITORY, sha); const { block: perfBlock } = buildPerfReport({ prevDir }); let header = buildHeader(report, perfBlock, { sha, subject: (args.subject as string) ?? commitSubject(sha), e2e: args.e2e as string, }); const { files, notes } = buildAttachments(root, report); if (notes.length) header += '\n' + notes.join('\n'); const messages = paginate(header, files); if (args.dryRun) { for (const [i, m] of messages.entries()) { console.log(`--- message ${i + 1}/${messages.length} (${m.files.length} files, ${m.files.reduce((s, f) => s + f.buffer.length, 0)} bytes) ---`); if (m.content) console.log(m.content); for (const f of m.files) console.log(` [attach] ${f.name} (${f.buffer.length} bytes)`); } return; } for (const m of messages) await postMessage(webhook!, m); console.log(`[discord] posted ${messages.length} message(s)`); } if (require.main === module) { main().catch((e) => { console.error(`[discord] ${e.message}`); process.exitCode = 1; }); }