2026-07-01 18:01:15 +02:00
/ * *
* 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' ;
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:10:40 +02:00
import { DIFF_OUT_DIR , splitKey } from './config' ;
2026-07-01 18:01:15 +02:00
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 ;
2026-07-01 20:06:51 +02:00
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
2026-07-01 18:01:15 +02:00
const FLOOD_N = 12 ; // more ADDED than this ⇒ show a few exemplars, not all
2026-07-01 20:06:51 +02:00
const REMOVED_CAP = 40 ; // cap the REMOVED name list in the message body
2026-07-01 18:01:15 +02:00
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 ;
}
}
2026-07-03 15:40:42 +02:00
/ * * O n e s t a t u s l i n e f o r t h e 3 D - r e n d e r e r r e g r e s s i o n s u i t e ( t e s t s / 3 d - r e g r e s s i o n ) ,
* 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 3 D 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' : '' } ` ] ;
}
2026-07-01 18:01:15 +02:00
/** 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 ) ;
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:10:40 +02:00
// 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 ( ' / ' ) } ) ` ;
} ;
2026-07-01 18:01:15 +02:00
lines . push ( '' ) ;
2026-08-19 08:51:44 +02:00
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 ) {
2026-07-01 18:01:15 +02:00
lines . push ( '✅ no screenshot drift' ) ;
} else if ( report . driftLikely ) {
screenshots: R2-hosted manifest becomes the baseline source of truth (morelli cutover)
The committed tests/screenshot-manifest.json is retired. CI now downloads
baselines/pcbjam/manifest.json (manifest v3, written only by the morelli
review app + its seed script) to the gitignored .baseline-manifest.json,
and everything downstream (pull, verify, compare) reads that copy:
- config.ts: MANIFEST_VERSION 3, MANIFEST_PATH .baseline-manifest.json,
R2_BASELINES_MANIFEST_KEY; ManifestEntry grows opaque provenance
- r2-sync.ts: new --manifest mode (atomic fetch; no-creds skip DELETES a
stale copy so the gate skips rather than using old baselines); --push
gone (bytes enter the CAS only via morelli's promote)
- compare.ts: hard-skips when no manifest was fetched — a stale warm
cache can never gate
- wasm-build.yml: fetch-manifest step before the baselines cache; cache
key now hashes the fetched manifest; the gen-manifest --check lint gate
goes with the committed manifest
- deleted: screenshot-manifest.json, promote.ts, changelog.ts,
gen-manifest.ts, screenshot-changelog.yml, promote-screenshots skill
- docs (CLAUDE/README/TESTING/WHATWORKS/tools README): promote flow is
now https://pcbjam-morelli-staging.pcbjam-staging.workers.dev
Validated locally against the real bucket: fetch-manifest (492), cold
pull 492 / warm pull cached=492, no-creds skip chain, compare gate skip.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 11:54:20 +02:00
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) ` ) ;
2026-07-01 18:01:15 +02:00
} else {
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:10:40 +02:00
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 ) } `
) ;
2026-07-01 18:01:15 +02:00
}
if ( report ? . removed . length ) {
2026-07-01 20:06:51 +02:00
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 ) ;
2026-07-01 18:01:15 +02:00
}
2026-07-03 15:40:42 +02:00
lines . push ( . . . threeDStatusLine ( ) ) ;
2026-07-01 18:01:15 +02:00
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 ) ;
}
2026-07-02 12:07:41 +02:00
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 ) ;
}
2026-07-01 18:01:15 +02:00
const shown = files . length ;
2026-07-02 12:07:41 +02:00
const wanted = report . changed . length + addedToShow . length + removedToShow . length ;
2026-07-01 18:01:15 +02:00
if ( wanted > shown ) notes . push ( ` ( ${ wanted - shown } more images omitted — see the CI artifact) ` ) ;
return { files , notes } ;
}
2026-07-01 20:06:51 +02:00
/ * *
* 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 .
* /
2026-07-01 18:01:15 +02:00
export function paginate ( header : string , files : Attachment [ ] ) : Message [ ] {
const messages : Message [ ] = [ ] ;
2026-07-01 20:06:51 +02:00
// 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.
2026-07-01 18:01:15 +02:00
let batch : Attachment [ ] = [ ] ;
let bytes = 0 ;
const flush = ( ) = > {
2026-07-01 20:06:51 +02:00
if ( batch . length ) messages . push ( { content : '' , files : batch } ) ;
2026-07-01 18:01:15 +02:00
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 ;
}
2026-07-01 20:06:51 +02:00
flush ( ) ;
return messages . length ? messages : [ { content : header , files : [ ] } ] ;
2026-07-01 18:01:15 +02:00
}
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 ) ;
2026-07-02 09:20:35 +02:00
// 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.
2026-07-01 20:06:51 +02:00
if ( report && report . changed . length + report . added . length + report . unchangedCount === 0 ) {
2026-07-02 09:20:35 +02:00
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)' ) ;
}
2026-07-01 20:06:51 +02:00
return ;
}
2026-07-01 18:01:15 +02:00
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 ;
} ) ;
}