pcbjam/tests/tools/screenshots/post-discord.ts
Viktor Vaczi 63ed1f3c1f e2e/CI: dual-engine suites, per-engine screenshots, SwiftShader retired, prod web suite, CI-coverage gate
Squash of experiment/ff-big-modules vs main.

Big-module routing removed: native-EH shrank kicad_editor below
SpiderMonkey's x86-64 code budget (runs 29355049705/29356152413 green on
stock Firefox), so BIG_MODULE_SPECS routing and the baseline-only-JIT
crutch are gone — kicad-firefox and kicad-chromium both run the full
suite, with the module compiled the way real users' browsers compile it.

Per-engine screenshots end to end: stableShot/shotPath write
test-results/<engine>/<name>.png; baselines move to
baseline-screenshots/{chromium,firefox}/ and the whole tools/screenshots
pipeline (compare/promote/manifest/spec-map/changelog/Discord) keys on
<engine>/<name>. Previously Firefox and Chromium renders of one spec
overwrote each other and Firefox renders were never actually gated.
Seeded from CI run 29421380806 (92 new firefox baselines, +24 chromium
web-suite shots); manifest generated from the baseline tree.

One merged playwright.config.ts (kicad/asyncify/coroutine/perf as
projects); ~25 dead npm scripts dropped. The web suite is gated in CI for
the first time ever (4 rotted specs fixed, 5 broken lib-bridge specs
triaged as fixme in docs/features/web-e2e-rot/); cheap lint step after
npm ci; last 26 blind-sleep violations fixed.

SwiftShader retired: CI Chromium renders WebGL on ANGLE → Mesa llvmpipe
(--use-gl=angle --use-angle=gl --ignore-gpu-blocklist; the blocklist flag
is mandatory — llvmpipe is blocklisted and WebGL is silently unavailable
without it) in BOTH configs. Under WORKERS=4 congestion SwiftShader
transiently failed the first post-board-load draw and the recovery
cascade ended in a silent permanent Cairo fallback — that engine flip was
the "~1.2% changedRatio both directions" occ-export baseline flake.
Validated 160/160 across two 80-repeat rigs; full analysis in
docs/features/wx-parity-bugs/occ-export-context-eviction.md. Chromium
baselines shift slightly on llvmpipe — promote once from the first green
run. Deflakes the new coverage exposed: presence baselines settle before
capture; presence fixtures declare current file formats; perf gets its
own outputDir so CI evidence survives; occ-export settles the board paint
before the export dialog; menu-item waits (waitForRenderedByLabel before
clickMenuItem) in 4 specs + the TESTING.md rule.

Web suite runs the PROD build, in parallel: webServer becomes backend
`start` + the standalone's e2e:preview (build-preview.mjs: link-wasm →
stash the public/wasm symlink aside during vite build, build-demo.mjs's
move — then vite preview as the persistent server). The wasm middleware
serves /wasm/* in preview and emits COOP/COEP/CORP itself (a pthread
worker script's own response must carry COEP or Chrome kills it with
ERR_BLOCKED_BY_RESPONSE). VITE_* flags bake at build time;
VITE_ALLOW_USER_OVERRIDE joins turbo globalEnv. fullyParallel + default
workers: 5.2m → 1.4m. Determinism fixes the parallel run exposed:
shared-page specs become serial groups; locks.spec grabs alice's exact
item via the new kicadCollabTestSelectByUuid hook (cross-tab "first
footprint" order is not a ysync invariant); quit specs poll page.url()
(quit supersedes its own navigation — NS_BINDING_ABORTED on Firefox).
Suite: 51 passed / 12 skipped / 0 failed in 1.6m.

CI-coverage gate (lint:ci-coverage): every tests/**/*.spec.ts must be
reachable from the npm scripts the workflows invoke — scraped from
.github/workflows/, resolved through package.json, coverage asked from
playwright --list itself. Rules: uncovered-spec + orphan-project (with a
documented LOCAL_ONLY_PROJECTS allowlist). Gating next to
lint:determinism; 138 spec files / 13 projects accounted for.

Product fixes kept from the investigations (reachable on real GPUs too):
wx 7799fd1be5 — paint flags clear before dispatch + Invalidate always
propagates; kicad 3dcfea5e45 — SwiftShader pass-boundary flush +
per-instance font texture + first-frame GL-error drain (GAL recovery
recovers instead of falling back to Cairo) + the user-facing eeschema
switch navigates again under __EMSCRIPTEN__ (project-sync's
FaceRegistered gate had rerouted it into the hidden sync player; caught
by the newly-gated web suite).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018eUxiPApHgGiu9NFyQfhAq
2026-07-17 12:21:54 +02:00

335 lines
14 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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<string, number>();
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 || (!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 (\`npm run screenshots:promote\`)`);
} 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<void> {
return new Promise((r) => setTimeout(r, ms));
}
export async function postMessage(webhook: string, msg: Message): Promise<void> {
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<string, string | boolean> {
const out: Record<string, string | boolean> = {};
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<void> {
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;
});
}