diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 735044e..b604675 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,11 @@ on: branches: [main] pull_request: +permissions: + contents: read + actions: read + pull-requests: write + jobs: verify: runs-on: ubuntu-latest @@ -26,4 +31,36 @@ jobs: run: npm test - name: Build and performance budget + id: budget run: npm run build + + - name: Upload perf snapshot + if: github.event_name == 'push' && github.ref == 'refs/heads/main' && steps.budget.outcome == 'success' + uses: actions/upload-artifact@v4 + with: + name: perf-snapshot + path: perf/snapshot.json + retention-days: 30 + + - name: Comment on the pull request + if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository && (steps.budget.outcome == 'success' || steps.budget.outcome == 'failure') + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + GITHUB_REPOSITORY: ${{ github.repository }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + if [ ! -f perf/snapshot.json ]; then + echo "No perf snapshot to comment on." + exit 0 + fi + mkdir -p perf-base + id=$(gh run list --workflow ci.yml --branch main --status success --limit 1 --json databaseId --jq '.[0].databaseId // empty') + if [ -n "$id" ]; then + gh run download "$id" --name perf-snapshot --dir perf-base || true + fi + args=(--head perf/snapshot.json --run-url "$RUN_URL" --post) + if [ -f perf-base/snapshot.json ]; then + args+=(--base perf-base/snapshot.json) + fi + node scripts/perf-comment.mjs "${args[@]}" diff --git a/.gitignore b/.gitignore index cdf0a71..6b549fa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ node_modules +perf/snapshot.json +perf-base/ .next out dist diff --git a/docs/perf/budgets.md b/docs/perf/budgets.md index bb55c17..a756ab7 100644 --- a/docs/perf/budgets.md +++ b/docs/perf/budgets.md @@ -20,6 +20,12 @@ LCP 2500 ms, INP 200 ms, CLS 0.1. These are the Core Web Vitals "good" threshold The production-labor benchmark (human minutes per finished audio minute) is a different number. It lives in [benchmark.md](../benchmark.md). +## Pull request comment + +CI writes `perf/snapshot.json` after the production build: parsed bytes and level-9 gzip for each budgeted route. A sticky comment, ``, diffs that file against the snapshot from the last successful build on `main`. `baselineBytes` is not the diff. A growth of at least 100 B or 1% is marked. The comment does not fail the build. The gzip ceiling still does. Raise `maxBytes` in the same change that grows a page. + +There is no browser bench in CI. The production-labor benchmark is human minutes, and a lab paint time would move with the machine. + ## Commands ```bash diff --git a/package.json b/package.json index c028b55..2f8f5fd 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "sfx:init": "tsx scripts/generate-sfx.ts", "mix": "tsx scripts/mix-episode.ts", "benchmark": "tsx scripts/benchmark.ts", - "test": "node --test scripts/bundle-budget.test.mjs", + "test": "node --test scripts/bundle-budget.test.mjs scripts/perf-comment.test.mjs", "perf:budget": "node scripts/check-bundle-budget.mjs", "postbuild": "node scripts/check-bundle-budget.mjs", "test:parser": "tsx scripts/test-parser.ts", diff --git a/scripts/check-bundle-budget.mjs b/scripts/check-bundle-budget.mjs index 7927a72..473a07a 100644 --- a/scripts/check-bundle-budget.mjs +++ b/scripts/check-bundle-budget.mjs @@ -11,7 +11,7 @@ * Print the measurement without enforcing: `node scripts/check-bundle-budget.mjs --print` */ import { createRequire } from "node:module"; -import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import path from "node:path"; import { pathToFileURL } from "node:url"; @@ -106,18 +106,18 @@ export function measureRoutes(distDir) { // that double-counts and no longer matches the First Load JS column. const appFiles = (pages["/_app"] ?? []).filter((file) => file.endsWith(".js")); - /** @type {Map} */ - const gzipCache = new Map(); - const gzip = (rel) => { - const cached = gzipCache.get(rel); + /** @type {Map} */ + const sizeCache = new Map(); + const sizeOf = (rel) => { + const cached = sizeCache.get(rel); if (cached != null) return cached; const abs = path.join(distDir, rel); - const bytes = gzipSize.fileSync(abs); - gzipCache.set(rel, bytes); - return bytes; + const measured = { parsed: statSync(abs).size, gzip: gzipSize.fileSync(abs) }; + sizeCache.set(rel, measured); + return measured; }; - /** @type {{ path: string, bytes: number }[]} */ + /** @type {{ path: string, bytes: number, parsed: number }[]} */ const routes = []; const seen = new Set(); for (const [key, files] of Object.entries(appManifest.pages ?? {})) { @@ -126,9 +126,14 @@ export function measureRoutes(distDir) { if (seen.has(route)) continue; seen.add(route); const js = [...new Set([...(files ?? []), ...appFiles])].filter((file) => file.endsWith(".js")); - let total = 0; - for (const file of js) total += gzip(file); - routes.push({ path: route, bytes: total }); + let gzip = 0; + let parsed = 0; + for (const file of js) { + const sized = sizeOf(file); + gzip += sized.gzip; + parsed += sized.parsed; + } + routes.push({ path: route, bytes: gzip, parsed }); } routes.sort((a, b) => a.path.localeCompare(b.path)); return routes; @@ -138,6 +143,7 @@ export function measureAssets(assets) { return assets.map((asset) => ({ path: asset.path, bytes: gzipSize.fileSync(asset.path), + parsed: statSync(asset.path).size, })); } @@ -168,12 +174,37 @@ export function measureMarkers(distDir, markers) { const hits = files.filter((file) => read(file).includes(marker.contains)); if (hits.length === 0) continue; let bytes = 0; - for (const file of hits) bytes += gzipSize.fileSync(file); - measured.push({ path: marker.id, bytes }); + let parsed = 0; + for (const file of hits) { + bytes += gzipSize.fileSync(file); + parsed += statSync(file).size; + } + measured.push({ path: marker.id, bytes, parsed }); } return measured; } +function writeSnapshot(bundles) { + const snapshotPath = path.resolve("perf/snapshot.json"); + let benches = []; + if (existsSync(snapshotPath)) { + try { + benches = readJson(snapshotPath).benches ?? []; + } catch { + benches = []; + } + } + writeFileSync(snapshotPath, `${JSON.stringify({ bundles, benches }, null, 2)}\n`); +} + +function snapshotBundles(routes, assets, markers) { + return [ + ...routes.map((route) => ({ name: route.path, parsed: route.parsed, gzip: route.bytes })), + ...assets.map((asset) => ({ name: asset.path, parsed: asset.parsed, gzip: asset.bytes })), + ...markers.map((marker) => ({ name: marker.path, parsed: marker.parsed, gzip: marker.bytes })), + ]; +} + function printRow(label, bytes, maxBytes) { const max = maxBytes == null ? "" : ` max ${formatKb(maxBytes)}`; console.log(` ${label.padEnd(32)} ${formatKb(bytes).padStart(10)}${max}`); @@ -189,6 +220,7 @@ function main() { const markerLimits = budgets?.markers ?? []; const assets = assetLimits.length > 0 ? measureAssets(assetLimits) : []; const markers = markerLimits.length > 0 ? measureMarkers(distDir, markerLimits) : []; + writeSnapshot(snapshotBundles(routes, assets, markers)); console.log("First-load JS (gzip, level 9 — same number as next build)"); const maxByRoute = new Map((budgets?.routes ?? []).map((route) => [route.path, route.maxBytes])); diff --git a/scripts/perf-comment.mjs b/scripts/perf-comment.mjs new file mode 100644 index 0000000..4a59e9b --- /dev/null +++ b/scripts/perf-comment.mjs @@ -0,0 +1,394 @@ +#!/usr/bin/env node +/** + * Compare two perf snapshots and print the sticky pull-request comment. + * This file is the same in design-system, ai-document-intelligence-console, audioforge, and bol. + * + * node scripts/perf-comment.mjs --head perf/snapshot.json --base perf-base/snapshot.json --run-url URL --post + * node scripts/perf-comment.mjs --head perf/snapshot.json --fresh-benches perf-base/benches.json --post + * + * --fresh-benches replaces the base benches. Pass it when paint must be compared inside one job. + * Leave it off when the base snapshot's benches are the diff. + * A bench with timing "noise" stays collapsed unless its counts change. Its duration and paint + * never raise an alarm. + */ +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +export const MARKER = ""; + +const MARK = "🔺"; +const BYTE_MARK = 100; +const BYTE_RATIO = 0.01; +const NOISE_MS = 5; +const NOISE_RATIO = 0.1; +const ALARM_RATIO = 0.2; + +function readJson(file) { + return JSON.parse(readFileSync(file, "utf8")); +} + +function signed(value, suffix) { + const sign = value > 0 ? "+" : value < 0 ? "-" : "+"; + return `${sign}${suffix}`; +} + +function percent(delta, base) { + if (!base) return null; + const value = (delta / base) * 100; + const body = `${Math.abs(value).toFixed(1)}%`; + return signed(value, body); +} + +function bytesCell(current, previous) { + if (previous == null) return `${current} B`; + const delta = current - previous; + const ratio = previous > 0 ? delta / previous : null; + const pct = percent(delta, previous); + const body = pct + ? `${signed(delta, `${Math.abs(delta)} B`)} (${pct})` + : signed(delta, `${Math.abs(delta)} B`); + const marked = delta > 0 && (delta >= BYTE_MARK || (ratio != null && ratio >= BYTE_RATIO)); + return `${current} B ${marked ? MARK : ""}${body}`; +} + +function ms(value) { + return `${value.toFixed(2)} ms`; +} + +function msDelta(current, previous) { + if (previous == null) return ms(current); + const delta = current - previous; + const pct = percent(delta, previous); + const body = pct + ? `${signed(delta, ms(Math.abs(delta)))} (${pct})` + : signed(delta, ms(Math.abs(delta))); + return `${ms(current)} ${body}`; +} + +function countItems(bench) { + if (bench.counts && bench.counts.length > 0) return bench.counts; + return [{ metric: "renders", value: bench.count, max: bench.countMax }]; +} + +function sameCounts(head, base) { + if (head.count !== base.count) return false; + const baseItems = new Map((base.counts ?? []).map((item) => [item.metric, item.value])); + const headItems = head.counts ?? []; + if (headItems.length !== (base.counts ?? []).length) return false; + return headItems.every((item) => baseItems.get(item.metric) === item.value); +} + +function withinNoise(head, base) { + if (!base || !sameCounts(head, base)) return false; + if (head.timing === "noise") return true; + const delta = Math.abs(head.durationMs - base.durationMs); + const ratio = base.durationMs === 0 ? (delta === 0 ? 0 : Infinity) : delta / base.durationMs; + return delta < NOISE_MS && ratio < NOISE_RATIO; +} + +function benchAlarms(head, base) { + const alarms = []; + if (base && head.timing !== "noise") { + const delta = head.durationMs - base.durationMs; + const ratio = base.durationMs === 0 ? (delta > 0 ? Infinity : 0) : delta / base.durationMs; + if (delta > 0 && ratio >= ALARM_RATIO) { + alarms.push({ + test: head.name, + metric: "duration", + change: `${MARK} +${delta.toFixed(2)} ms`, + }); + } + } + const items = countItems(head); + const baseByMetric = new Map( + countItems(base ?? { count: base?.count }).map((item) => [item.metric, item.value]), + ); + if (!base) baseByMetric.clear(); + for (const item of items) { + const previous = base ? baseByMetric.get(item.metric) : undefined; + const ceilingOnly = item.alarm === "ceiling"; + if (!ceilingOnly && previous != null && previous > 0) { + const delta = item.value - previous; + if (delta > 0 && delta / previous >= ALARM_RATIO) { + alarms.push({ test: head.name, metric: item.metric, change: `${MARK} +${delta}` }); + } + } + if (item.max != null && item.value > item.max) { + alarms.push({ + test: head.name, + metric: item.metric, + change: `${MARK} ${item.value} over ${item.max}`, + }); + } + } + return alarms; +} + +function durationAlarm(head, base) { + return benchAlarms(head, base).some((alarm) => alarm.metric === "duration"); +} + +function renderAlarm(head, base) { + return benchAlarms(head, base).some( + (alarm) => alarm.metric === "renders" || alarm.metric === "count", + ); +} + +function sum(benches, key) { + return benches.reduce((total, bench) => total + bench[key], 0); +} + +function fileRows(head, base) { + const baseBundles = new Map((base?.bundles ?? []).map((bundle) => [bundle.name, bundle])); + const rows = []; + for (const bundle of head.bundles ?? []) { + const previous = new Map( + (baseBundles.get(bundle.name)?.files ?? []).map((file) => [file.path, file]), + ); + for (const file of bundle.files ?? []) { + const before = previous.get(file.path); + rows.push({ + bundle: bundle.name, + path: file.path, + parsed: bytesCell(file.parsed, before?.parsed), + gzip: bytesCell(file.gzip, before?.gzip), + }); + } + } + return rows; +} + +function countDetails(benches) { + const rows = []; + for (const bench of benches) { + for (const item of bench.counts ?? []) { + rows.push({ + test: bench.name, + metric: item.metric, + value: item.value, + max: item.max, + }); + } + } + return rows; +} + +/** + * @param {{ head: { bundles?: object[], benches?: object[] }, base?: { bundles?: object[], benches?: object[] } | null, runUrl?: string }} input + */ +export function renderComment({ head, base, runUrl }) { + const bundles = head.bundles ?? []; + const benches = head.benches ?? []; + const baseBundles = new Map((base?.bundles ?? []).map((bundle) => [bundle.name, bundle])); + const baseBenches = new Map((base?.benches ?? []).map((bench) => [bench.name, bench])); + const hasBase = + base != null && ((base.bundles ?? []).length > 0 || (base.benches ?? []).length > 0); + const lines = [MARKER, ""]; + + if (!hasBase) { + lines.push("Baseline arrives on the next main build.", ""); + } + + lines.push("## Bundle size", ""); + lines.push("| Bundle | Parsed size | Gzip size |"); + lines.push("| --- | --- | --- |"); + for (const bundle of bundles) { + const previous = baseBundles.get(bundle.name); + lines.push( + `| ${bundle.name} | ${bytesCell(bundle.parsed, previous?.parsed)} | ${bytesCell(bundle.gzip, previous?.gzip)} |`, + ); + } + lines.push(""); + + const files = fileRows(head, base); + if (files.length > 0) { + lines.push("
"); + lines.push("Details of bundle changes", ""); + lines.push("| Bundle | File | Parsed | Gzip |"); + lines.push("| --- | --- | --- | --- |"); + for (const file of files) { + lines.push(`| ${file.bundle} | ${file.path} | ${file.parsed} | ${file.gzip} |`); + } + lines.push("", "
", ""); + } + + if (benches.length > 0) { + const baseList = benches.map((bench) => baseBenches.get(bench.name)).filter(Boolean); + const comparable = baseList.length === benches.length; + lines.push("## Performance", ""); + const duration = sum(benches, "durationMs"); + const renders = sum(benches, "count"); + const paint = sum(benches, "paintMs"); + const baseDuration = comparable ? sum(baseList, "durationMs") : null; + const baseRenders = comparable ? sum(baseList, "count") : null; + const basePaint = comparable ? sum(baseList, "paintMs") : null; + const renderDelta = + baseRenders == null + ? `${renders}` + : `${renders} (${signed(renders - baseRenders, String(Math.abs(renders - baseRenders)))})`; + lines.push( + `Total duration: ${msDelta(duration, baseDuration)} | Renders: ${renderDelta} | Paint: ${msDelta(paint, basePaint)}`, + "", + ); + + const visible = []; + let hidden = 0; + for (const bench of benches) { + const previous = baseBenches.get(bench.name); + if (previous && withinNoise(bench, previous)) hidden += 1; + else visible.push(bench); + } + if (visible.length > 0) { + lines.push("| Test | Duration | Renders | Paint |"); + lines.push("| --- | --- | --- | --- |"); + for (const bench of visible) { + const previous = baseBenches.get(bench.name); + const durationCell = msDelta(bench.durationMs, previous?.durationMs); + const markedDuration = + previous && durationAlarm(bench, previous) + ? durationCell.replace(" ms ", ` ms ${MARK}`) + : durationCell; + const countDelta = + previous == null + ? String(bench.count) + : `${bench.count} (${signed(bench.count - previous.count, String(Math.abs(bench.count - previous.count)))})`; + const markedCount = + previous && renderAlarm(bench, previous) ? `${MARK}${countDelta}` : countDelta; + lines.push( + `| ${bench.name} | ${markedDuration} | ${markedCount} | ${msDelta(bench.paintMs, previous?.paintMs)} |`, + ); + } + lines.push(""); + } + if (hidden > 0) { + lines.push(`${hidden} test${hidden === 1 ? "" : "s"} within noise`, ""); + } + + const mounts = countDetails(benches); + if (mounts.length > 0) { + lines.push("
"); + lines.push("Mount counts", ""); + lines.push("| Test | Metric | Count | Ceiling |"); + lines.push("| --- | --- | ---: | ---: |"); + for (const mount of mounts) { + lines.push(`| ${mount.test} | ${mount.metric} | ${mount.value} | ${mount.max ?? ""} |`); + } + lines.push("", "
", ""); + } + + const alarms = benches.flatMap((bench) => benchAlarms(bench, baseBenches.get(bench.name))); + lines.push("## Metric alarms", ""); + if (alarms.length === 0) { + lines.push("None.", ""); + } else { + lines.push("| Test | Metric | Change |"); + lines.push("| --- | --- | --- |"); + for (const alarm of alarms) { + lines.push(`| ${alarm.test} | ${alarm.metric} | ${alarm.change} |`); + } + lines.push(""); + } + } + + if (runUrl) { + lines.push(`[Details of this run](${runUrl})`, ""); + } + return `${lines.join("\n").trim()}\n`; +} + +function headers(token) { + return { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + "User-Agent": "perf-budget", + }; +} + +export async function upsertComment({ body, repo, pr, token, fetchImpl = fetch }) { + let existing = null; + for (let page = 1; page <= 5; page += 1) { + const listed = await fetchImpl( + `https://api.github.com/repos/${repo}/issues/${pr}/comments?per_page=100&page=${page}`, + { headers: headers(token) }, + ); + if (!listed.ok) throw new Error(`Listing comments failed (${listed.status}).`); + const comments = await listed.json(); + existing = comments.find((comment) => String(comment.body ?? "").includes(MARKER)) ?? null; + if (existing || comments.length < 100) break; + } + if (existing) { + const updated = await fetchImpl( + `https://api.github.com/repos/${repo}/issues/comments/${existing.id}`, + { + method: "PATCH", + headers: { ...headers(token), "Content-Type": "application/json" }, + body: JSON.stringify({ body }), + }, + ); + if (!updated.ok) throw new Error(`Updating the comment failed (${updated.status}).`); + return; + } + const created = await fetchImpl(`https://api.github.com/repos/${repo}/issues/${pr}/comments`, { + method: "POST", + headers: { ...headers(token), "Content-Type": "application/json" }, + body: JSON.stringify({ body }), + }); + if (!created.ok) throw new Error(`Posting the comment failed (${created.status}).`); +} + +function arg(name) { + const index = process.argv.indexOf(name); + if (index === -1) return null; + return process.argv[index + 1] ?? null; +} + +function loadOptional(file) { + if (!file || !existsSync(file)) return null; + return readJson(file); +} + +async function main() { + const headPath = arg("--head"); + if (!headPath || !existsSync(headPath)) { + console.error("Missing --head snapshot."); + process.exitCode = 1; + return; + } + const head = readJson(headPath); + const base = loadOptional(arg("--base")); + const freshPath = arg("--fresh-benches"); + const fresh = freshPath ? (loadOptional(freshPath) ?? { benches: [] }) : null; + const resolvedBase = fresh + ? { bundles: base?.bundles ?? [], benches: fresh.benches ?? [] } + : base; + const hasResolvedBase = + resolvedBase != null && + ((resolvedBase.bundles ?? []).length > 0 || (resolvedBase.benches ?? []).length > 0); + const markdown = renderComment({ + head, + base: hasResolvedBase ? resolvedBase : null, + runUrl: arg("--run-url") ?? undefined, + }); + const out = arg("--out"); + if (out) writeFileSync(out, markdown); + else process.stdout.write(markdown); + + if (!process.argv.includes("--post")) return; + const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN; + const repo = process.env.GITHUB_REPOSITORY; + const pr = process.env.PR_NUMBER; + if (!token || !repo || !pr) { + console.error("Skipping the comment. GH_TOKEN, GITHUB_REPOSITORY, and PR_NUMBER are required."); + return; + } + await upsertComment({ body: markdown, repo, pr, token }); +} + +const entry = process.argv[1] ? pathToFileURL(path.resolve(process.argv[1])).href : ""; +if (import.meta.url === entry) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + }); +} diff --git a/scripts/perf-comment.test.mjs b/scripts/perf-comment.test.mjs new file mode 100644 index 0000000..9366947 --- /dev/null +++ b/scripts/perf-comment.test.mjs @@ -0,0 +1,136 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { renderComment } from "./perf-comment.mjs"; + +const headBundles = { + bundles: [ + { name: "catalog JS", parsed: 240120, gzip: 71020 }, + { name: "theme.css", parsed: 1100, gzip: 505 }, + { name: "presets.css", parsed: 3000, gzip: 900 }, + ], + benches: [], +}; + +const baseBundles = { + bundles: [ + { name: "catalog JS", parsed: 240000, gzip: 71000 }, + { name: "theme.css", parsed: 1000, gzip: 500 }, + { name: "presets.css", parsed: 3200, gzip: 1000 }, + ], + benches: [], +}; + +test("marks a bundle growth of at least 100 B or 1% and leaves a shrink unmarked", () => { + const markdown = renderComment({ head: headBundles, base: baseBundles }); + assert.match( + markdown, + /\| catalog JS \| 240120 B 🔺\+120 B \(\+0\.1%\) \| 71020 B \+20 B \(\+0\.0%\) \|/, + ); + assert.match( + markdown, + /\| theme\.css \| 1100 B 🔺\+100 B \(\+10\.0%\) \| 505 B 🔺\+5 B \(\+1\.0%\) \|/, + ); + assert.match( + markdown, + /\| presets\.css \| 3000 B -200 B \(-6\.3%\) \| 900 B -100 B \(-10\.0%\) \|/, + ); + assert.doesNotMatch(markdown, /presets\.css \| 3000 B 🔺/); +}); + +test("collapses a bench inside the noise band and does not alarm on paint", () => { + const markdown = renderComment({ + head: { + bundles: [], + benches: [{ name: "Catalog mount", durationMs: 42, count: 5, paintMs: 30 }], + }, + base: { + bundles: [], + benches: [{ name: "Catalog mount", durationMs: 40, count: 5, paintMs: 12 }], + }, + }); + assert.match(markdown, /1 test within noise/); + assert.doesNotMatch(markdown, /\| Catalog mount \|/); + assert.match(markdown, /## Metric alarms\n\nNone\./); +}); + +test("alarms when duration regresses by at least 20%", () => { + const markdown = renderComment({ + head: { + bundles: [], + benches: [{ name: "Catalog mount", durationMs: 52, count: 5, paintMs: 12 }], + }, + base: { + bundles: [], + benches: [{ name: "Catalog mount", durationMs: 40, count: 5, paintMs: 12 }], + }, + }); + assert.match(markdown, /\| Catalog mount \|/); + assert.match(markdown, /\| Catalog mount \| duration \| 🔺 \+12\.00 ms \|/); + assert.doesNotMatch(markdown, /within noise/); +}); + +test("alarms when a count crosses its ceiling, including with no baseline", () => { + const markdown = renderComment({ + head: { + bundles: [], + benches: [ + { + name: "Job console", + durationMs: 100, + count: 20, + paintMs: 10, + timing: "noise", + counts: [{ metric: "pages", value: 20, max: 12 }], + }, + ], + }, + base: null, + }); + assert.match(markdown, /Baseline arrives on the next main build\./); + assert.match(markdown, /\| Job console \| pages \| 🔺 20 over 12 \|/); +}); + +test("a noisy timing stays collapsed when the counts do not change", () => { + const markdown = renderComment({ + head: { + bundles: [], + benches: [ + { + name: "Home list", + durationMs: 800, + count: 13, + paintMs: 400, + timing: "noise", + counts: [{ metric: "jobs", value: 13, max: 32 }], + }, + ], + }, + base: { + bundles: [], + benches: [ + { + name: "Home list", + durationMs: 200, + count: 13, + paintMs: 50, + timing: "noise", + counts: [{ metric: "jobs", value: 13, max: 32 }], + }, + ], + }, + }); + assert.match(markdown, /1 test within noise/); + assert.match(markdown, /## Metric alarms\n\nNone\./); + assert.doesNotMatch(markdown, /\| Home list \| 800/); +}); + +test("prints absolute sizes when the baseline has not been stored", () => { + const markdown = renderComment({ + head: { bundles: [{ name: "catalog JS", parsed: 240000, gzip: 71000 }], benches: [] }, + base: null, + }); + assert.match(markdown, /Baseline arrives on the next main build\./); + assert.match(markdown, /\| catalog JS \| 240000 B \| 71000 B \|/); + assert.doesNotMatch(markdown, /🔺/); + assert.doesNotMatch(markdown, /## Performance/); +});