@decohere/ci-tools 0.0.1-dev.109 → 0.0.1-dev.110

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,313 @@
1
+ /**
2
+ * The benchmark page on Pages (#660): a trend chart per benchmark and a table of the latest
3
+ * results, from the version 2 history (../bench/history.mjs). Static HTML and inline SVG, so the
4
+ * page needs no script and no third-party chart library.
5
+ */
6
+
7
+ import { REGRESSION } from "../bench/history.mjs";
8
+
9
+ const GROUPS = [
10
+ { prefix: "flux.serve.", title: "flux serve (real process)" },
11
+ { prefix: "flux.load.", title: "flux load (in process)" },
12
+ { prefix: "flux.transpile.", title: "Transpile strategies (SWC only)" },
13
+ { prefix: "forge.build.", title: "forge build" },
14
+ ];
15
+
16
+ const SERIES_COLORS = [
17
+ "var(--series-1)",
18
+ "var(--series-2)",
19
+ "var(--series-3)",
20
+ "var(--series-4)",
21
+ ];
22
+
23
+ function escapeHtml(value) {
24
+ return String(value)
25
+ .replaceAll("&", "&")
26
+ .replaceAll("<", "&lt;")
27
+ .replaceAll(">", "&gt;")
28
+ .replaceAll('"', "&quot;");
29
+ }
30
+
31
+ function fmtMs(ms) {
32
+ if (typeof ms !== "number" || Number.isNaN(ms)) return "–";
33
+ if (ms === 0) return "0 ms";
34
+ if (ms >= 100) return `${Math.round(ms)} ms`;
35
+ return `${ms.toFixed(1)} ms`;
36
+ }
37
+
38
+ /** `2026-10-20 03:17 UTC`: when a run happened, to the minute. */
39
+ function fmtDate(iso) {
40
+ return `${iso.slice(0, 10)} ${iso.slice(11, 16)} UTC`;
41
+ }
42
+
43
+ function commitLink(repoUrl, sha) {
44
+ if (sha == null) return "–";
45
+ return `<a href="${escapeHtml(`${repoUrl}/commit/${sha}`)}"><code>${escapeHtml(sha.slice(0, 7))}</code></a>`;
46
+ }
47
+
48
+ function machineName(context) {
49
+ return `${context.benchEnv ?? "unknown"} · ${context.cpu.model} × ${context.cpu.count}`;
50
+ }
51
+
52
+ /** Contexts with at least one run, most recently run first. */
53
+ function activeContexts(history) {
54
+ return Object.entries(history.contexts ?? {})
55
+ .filter(([, entry]) => entry.runs.length > 0)
56
+ .sort((a, b) => new Date(b[1].runs[0].date) - new Date(a[1].runs[0].date))
57
+ .map(([id, entry], index) => ({
58
+ id,
59
+ context: entry.context,
60
+ runs: entry.runs,
61
+ color: SERIES_COLORS[index % SERIES_COLORS.length],
62
+ }));
63
+ }
64
+
65
+ /** Every benchmark id seen in any run, with the label from its newest run on any machine. */
66
+ function benchmarkIds(contexts) {
67
+ const newest = new Map();
68
+ for (const { runs } of contexts) {
69
+ for (const run of runs) {
70
+ const time = new Date(run.date).getTime();
71
+ for (const result of run.results) {
72
+ const seen = newest.get(result.id);
73
+ if (seen == null || time > seen.time)
74
+ newest.set(result.id, { time, label: result.label });
75
+ }
76
+ }
77
+ }
78
+ return new Map([...newest].map(([id, { label }]) => [id, label]));
79
+ }
80
+
81
+ /** Oldest-first points for one benchmark on one machine. */
82
+ function seriesFor(runs, id) {
83
+ return [...runs]
84
+ .reverse()
85
+ .map((run) => ({ run, result: run.results.find((result) => result.id === id) }))
86
+ .filter((point) => point.result != null)
87
+ .map(({ run, result }) => ({
88
+ time: new Date(run.date).getTime(),
89
+ date: run.date.slice(0, 10),
90
+ iso: run.date,
91
+ sha: run.sha,
92
+ median: result.median,
93
+ p90: result.p90,
94
+ }));
95
+ }
96
+
97
+ /** A line chart of medians over time, one line per machine, with a hover title per point. */
98
+ export function trendSvg(lines) {
99
+ const width = 560;
100
+ const height = 180;
101
+ const pad = { top: 12, right: 12, bottom: 24, left: 56 };
102
+ const points = lines.flatMap((line) => line.points);
103
+ if (points.length === 0) return "";
104
+ const minTime = Math.min(...points.map((p) => p.time));
105
+ const maxTime = Math.max(...points.map((p) => p.time));
106
+ const maxValue = Math.max(...points.map((p) => p.p90 ?? p.median)) * 1.1 || 1;
107
+ const x = (time) =>
108
+ maxTime === minTime
109
+ ? pad.left + (width - pad.left - pad.right) / 2
110
+ : pad.left + ((time - minTime) / (maxTime - minTime)) * (width - pad.left - pad.right);
111
+ const y = (value) => pad.top + (1 - value / maxValue) * (height - pad.top - pad.bottom);
112
+ const ticks = [0, 0.5, 1].map((fraction) => maxValue * fraction);
113
+ const grid = ticks
114
+ .map(
115
+ (value) =>
116
+ `<line class="grid" x1="${pad.left}" x2="${width - pad.right}" y1="${y(value)}" y2="${y(value)}"/>` +
117
+ `<text class="axis" x="${pad.left - 6}" y="${y(value) + 4}" text-anchor="end">${fmtMs(value)}</text>`,
118
+ )
119
+ .join("");
120
+ const dateLabels = [
121
+ { time: minTime, anchor: "start" },
122
+ ...(maxTime !== minTime ? [{ time: maxTime, anchor: "end" }] : []),
123
+ ]
124
+ .map(
125
+ ({ time, anchor }) =>
126
+ `<text class="axis" x="${x(time)}" y="${height - 6}" text-anchor="${anchor}">${new Date(time).toISOString().slice(0, 10)}</text>`,
127
+ )
128
+ .join("");
129
+ const series = lines
130
+ .map((line) => {
131
+ const path = line.points
132
+ .map((p) => `${x(p.time).toFixed(1)},${y(p.median).toFixed(1)}`)
133
+ .join(" ");
134
+ const dots = line.points
135
+ .map(
136
+ (p) =>
137
+ `<circle cx="${x(p.time).toFixed(1)}" cy="${y(p.median).toFixed(1)}" r="3" style="fill:${line.color}"><title>${escapeHtml(
138
+ `${p.date}${p.sha ? ` · ${p.sha.slice(0, 7)}` : ""} · median ${fmtMs(p.median)}${p.p90 != null ? ` · p90 ${fmtMs(p.p90)}` : ""}`,
139
+ )}</title></circle>`,
140
+ )
141
+ .join("");
142
+ return `<polyline points="${path}" style="stroke:${line.color}"/>${dots}`;
143
+ })
144
+ .join("");
145
+ return `<svg class="trend" viewBox="0 0 ${width} ${height}" role="img" aria-label="Median over time">${grid}${dateLabels}${series}</svg>`;
146
+ }
147
+
148
+ function changeText(points) {
149
+ const last = points.at(-1);
150
+ const before = points.at(-2);
151
+ if (last == null || before == null || before.median === 0) return "";
152
+ const percent = ((last.median - before.median) / before.median) * 100;
153
+ const direction = percent > 0 ? "slower" : "faster";
154
+ if (Math.abs(percent) < 0.5) return `<span class="change">no change</span>`;
155
+ return `<span class="change ${percent > 0 ? "worse" : "better"}">${Math.abs(percent).toFixed(1)}% ${direction}</span>`;
156
+ }
157
+
158
+ function card(id, label, contexts) {
159
+ const lines = contexts
160
+ .map((ctx) => ({
161
+ color: ctx.color,
162
+ name: machineName(ctx.context),
163
+ points: seriesFor(ctx.runs, id),
164
+ }))
165
+ .filter((line) => line.points.length > 0);
166
+ const primary = lines[0];
167
+ const latest = primary?.points.at(-1);
168
+ return `<article class="card">
169
+ <header>
170
+ <h3>${escapeHtml(label)}</h3>
171
+ <code>${escapeHtml(id)}</code>
172
+ </header>
173
+ <p class="latest"><strong>${fmtMs(latest?.median)}</strong> median${latest?.p90 != null ? ` · p90 ${fmtMs(latest.p90)}` : ""} ${primary ? changeText(primary.points) : ""}</p>
174
+ <p class="when">${latest != null ? `Last run ${escapeHtml(fmtDate(latest.iso))}` : ""}</p>
175
+ ${trendSvg(lines)}
176
+ </article>`;
177
+ }
178
+
179
+ function latestTable(contexts, labels, repoUrl) {
180
+ const rows = contexts.flatMap((ctx) =>
181
+ ctx.runs[0].results.map(
182
+ (result) => `<tr>
183
+ <td><span class="swatch" style="background:${ctx.color}"></span>${escapeHtml(ctx.context.benchEnv ?? "unknown")}</td>
184
+ <td>${escapeHtml(fmtDate(ctx.runs[0].date))}</td>
185
+ <td>${commitLink(repoUrl, ctx.runs[0].sha)}</td>
186
+ <td>${escapeHtml(labels.get(result.id) ?? result.label)}</td>
187
+ <td class="num">${fmtMs(result.median)}</td>
188
+ <td class="num">${fmtMs(result.p90)}</td>
189
+ <td class="num">${fmtMs(result.min)}</td>
190
+ <td class="num">${result.files ?? "–"}</td>
191
+ <td class="num">${result.samples}</td>
192
+ </tr>`,
193
+ ),
194
+ );
195
+ return `<div class="table-wrap"><table>
196
+ <thead><tr><th>Machine</th><th>Run</th><th>Commit</th><th>Benchmark</th><th class="num">Median</th><th class="num">p90</th><th class="num">Min</th><th class="num">Files</th><th class="num">Samples</th></tr></thead>
197
+ <tbody>${rows.join("\n")}</tbody>
198
+ </table></div>`;
199
+ }
200
+
201
+ export function buildBenchmarkHtml(
202
+ history,
203
+ { branch = "local", sha = "local", repoUrl = "https://github.com/typesmiths/decohere" } = {},
204
+ ) {
205
+ const contexts = activeContexts(history);
206
+ const body =
207
+ contexts.length === 0
208
+ ? `<p class="empty">No benchmark history yet. The nightly bench workflow adds a run each night.</p>`
209
+ : (() => {
210
+ const labels = benchmarkIds(contexts);
211
+ const grouped = GROUPS.map((group) => ({
212
+ ...group,
213
+ ids: [...labels.keys()].filter((id) => id.startsWith(group.prefix)),
214
+ }));
215
+ const other = [...labels.keys()].filter(
216
+ (id) => !GROUPS.some((group) => id.startsWith(group.prefix)),
217
+ );
218
+ if (other.length > 0) grouped.push({ title: "Other", ids: other });
219
+ const sections = grouped
220
+ .filter((group) => group.ids.length > 0)
221
+ .map(
222
+ (group) => `<section>
223
+ <h2>${escapeHtml(group.title)}</h2>
224
+ <div class="cards">${group.ids.map((id) => card(id, labels.get(id), contexts)).join("\n")}</div>
225
+ </section>`,
226
+ )
227
+ .join("\n");
228
+ const legend = contexts
229
+ .map(
230
+ (ctx) =>
231
+ `<li><span class="swatch" style="background:${ctx.color}"></span>${escapeHtml(machineName(ctx.context))} · ${escapeHtml(ctx.context.os.platform)} ${escapeHtml(ctx.context.os.arch)} · ${ctx.context.memoryGb} GB · Node ${escapeHtml(ctx.context.node)} · ${ctx.runs.length} run(s)</li>`,
232
+ )
233
+ .join("\n");
234
+ return `<section class="machines"><h2>Machines</h2><ul>${legend}</ul>
235
+ <p class="note">Each line is one machine type; runs are only compared on the same one. Shared CI runners are noisy, so read the trend, not a single point. Hover a point for its date, commit and p90. A benchmark more than ${Math.round(REGRESSION.threshold * 100)}% slower than its baseline on ${REGRESSION.confirmRuns} nights running gets an issue: <a href="${escapeHtml(`${repoUrl}/issues?q=is%3Aissue+is%3Aopen+%22perf(bench)%22`)}">open benchmark issues</a>.</p></section>
236
+ ${sections}
237
+ <section><h2>Latest run</h2>${latestTable(contexts, labels, repoUrl)}</section>`;
238
+ })();
239
+ const updated = contexts[0]?.runs[0].date.slice(0, 10) ?? "never";
240
+ return `<!doctype html>
241
+ <html lang="en">
242
+ <head>
243
+ <meta charset="utf-8" />
244
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
245
+ <title>Decohere Benchmarks</title>
246
+ <link rel="stylesheet" href="./styles.css" />
247
+ </head>
248
+ <body>
249
+ <main class="container">
250
+ <header class="hero">
251
+ <h1>Decohere benchmarks</h1>
252
+ <p>Updated ${escapeHtml(updated)} · built from ${escapeHtml(branch)} @ <code>${escapeHtml(sha.slice(0, 12))}</code> · <a href="./data/bench-history.json">raw data</a></p>
253
+ </header>
254
+ ${body}
255
+ </main>
256
+ </body>
257
+ </html>
258
+ `;
259
+ }
260
+
261
+ export function buildStylesCss() {
262
+ return `:root {
263
+ --bg: #f6f7f9; --surface: #ffffff; --text: #1b1f24; --muted: #59636e; --border: #d8dee4;
264
+ --accent: #0b6e4f; --better: #1a7f37; --worse: #cf222e;
265
+ --series-1: #0b6e4f; --series-2: #8250df; --series-3: #bc4c00; --series-4: #0969da;
266
+ color-scheme: light dark;
267
+ }
268
+ @media (prefers-color-scheme: dark) {
269
+ :root:not([data-theme="light"]) {
270
+ --bg: #0d1117; --surface: #161b22; --text: #e6edf3; --muted: #9198a1; --border: #30363d;
271
+ --accent: #3fb68b; --better: #3fb950; --worse: #f85149;
272
+ --series-1: #3fb68b; --series-2: #a371f7; --series-3: #f0883e; --series-4: #4493f8;
273
+ }
274
+ }
275
+ :root[data-theme="dark"] {
276
+ --bg: #0d1117; --surface: #161b22; --text: #e6edf3; --muted: #9198a1; --border: #30363d;
277
+ --accent: #3fb68b; --better: #3fb950; --worse: #f85149;
278
+ --series-1: #3fb68b; --series-2: #a371f7; --series-3: #f0883e; --series-4: #4493f8;
279
+ }
280
+ * { box-sizing: border-box; }
281
+ body { margin: 0; background: var(--bg); color: var(--text); font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
282
+ a { color: var(--accent); }
283
+ code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.85em; }
284
+ .container { max-width: 1180px; margin: 0 auto; padding: 24px 16px 64px; }
285
+ .hero h1 { margin: 0 0 4px; font-size: 1.6rem; }
286
+ .hero p, .note, .empty { color: var(--muted); margin: 0; }
287
+ section { margin-top: 32px; }
288
+ h2 { font-size: 1.1rem; margin: 0 0 12px; }
289
+ .machines ul { list-style: none; padding: 0; margin: 0 0 8px; }
290
+ .machines li { margin: 4px 0; }
291
+ .swatch { display: inline-block; width: 10px; height: 10px; border-radius: 2px; margin-right: 8px; vertical-align: baseline; }
292
+ .cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 360px), 1fr)); gap: 16px; }
293
+ .card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 14px 16px; min-width: 0; }
294
+ .card header { display: flex; flex-direction: column; gap: 2px; }
295
+ .card h3 { font-size: 0.95rem; margin: 0; }
296
+ .card header code { color: var(--muted); }
297
+ .latest { margin: 8px 0 0; }
298
+ .when { margin: 0 0 4px; color: var(--muted); font-size: 0.85rem; }
299
+ .latest strong { font-size: 1.25rem; font-variant-numeric: tabular-nums; }
300
+ .change { margin-left: 6px; font-size: 0.85rem; color: var(--muted); }
301
+ .change.better { color: var(--better); }
302
+ .change.worse { color: var(--worse); }
303
+ .trend { width: 100%; height: auto; display: block; }
304
+ .trend polyline { fill: none; stroke-width: 2; }
305
+ .trend .grid { stroke: var(--border); stroke-width: 1; }
306
+ .trend .axis { fill: var(--muted); font-size: 11px; }
307
+ .table-wrap { overflow-x: auto; }
308
+ table { width: 100%; border-collapse: collapse; background: var(--surface); border: 1px solid var(--border); border-radius: 10px; font-variant-numeric: tabular-nums; }
309
+ th, td { padding: 8px 10px; border-bottom: 1px solid var(--border); text-align: left; white-space: nowrap; }
310
+ th { color: var(--muted); font-weight: 600; }
311
+ .num { text-align: right; }
312
+ `;
313
+ }
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Restore the badges on the live Pages site into dist, so a deploy that does not regenerate
4
+ // them (the nightly bench) keeps them. Pages replaces the whole site on every deploy.
5
+ //
6
+ // The site lists its badges in badges/manifest.json (artifact.mjs). Sites deployed before the
7
+ // manifest existed are read with a fixed list instead. A badge the site does not have (404) is
8
+ // skipped; any other failure stops the job.
9
+
10
+ import { mkdir, writeFile } from "node:fs/promises";
11
+ import path from "node:path";
12
+
13
+ const BADGES_DIR = "projects/ci-tools/dist/ci/badges";
14
+ const PAGES_URL = process.env.PAGES_URL ?? "https://typesmiths.github.io/decohere";
15
+
16
+ const LEGACY_BADGES = [
17
+ "ci-main.svg",
18
+ "ci-release.svg",
19
+ "coverage.svg",
20
+ ...[
21
+ "ci-tools",
22
+ "cli",
23
+ "codex",
24
+ "cortex",
25
+ "create",
26
+ "crucible",
27
+ "flux",
28
+ "forge",
29
+ "mesh",
30
+ "nexus",
31
+ "protocol",
32
+ "scribe",
33
+ "terminal",
34
+ ].map((project) => `projects/${project}.svg`),
35
+ ];
36
+
37
+ async function fetchOrNull(url) {
38
+ const response = await fetch(url);
39
+ if (response.status === 404) return null;
40
+ if (!response.ok) throw new Error(`GET ${url}: HTTP ${response.status}`);
41
+ return response;
42
+ }
43
+
44
+ const manifest = await fetchOrNull(`${PAGES_URL}/badges/manifest.json`);
45
+ const badges = manifest != null ? (await manifest.json()).badges : LEGACY_BADGES;
46
+
47
+ let restored = 0;
48
+ for (const badge of badges) {
49
+ const response = await fetchOrNull(`${PAGES_URL}/badges/${badge}`);
50
+ if (response == null) continue;
51
+ const file = path.join(BADGES_DIR, badge);
52
+ await mkdir(path.dirname(file), { recursive: true });
53
+ await writeFile(file, await response.text());
54
+ restored += 1;
55
+ }
56
+ console.log(
57
+ `✓ Restored ${restored} badge(s) from Pages (${manifest != null ? "manifest" : "legacy list"})`,
58
+ );
@@ -1,37 +1,24 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- // Restore benchmark history from Pages, or initialize empty if not found
3
+ // Restore benchmark history from Pages. Only a 404 (never published, or cleared) starts an
4
+ // empty history: any other failure stops the job, so a network blip can't wipe the trend on
5
+ // the next save.
4
6
 
5
- import { execSync } from "node:child_process";
6
- import { writeFileSync } from "node:fs";
7
- import { mkdir } from "node:fs/promises";
7
+ import { mkdir, writeFile } from "node:fs/promises";
8
8
 
9
9
  const HISTORY_DIR = "projects/ci-tools/dist/ci/bench";
10
10
  const HISTORY_FILE = `${HISTORY_DIR}/bench-history.json`;
11
- const PAGES_URL = "https://typesmiths.github.io/decohere/data/bench-history.json";
11
+ const PAGES_URL = process.env.PAGES_URL ?? "https://typesmiths.github.io/decohere";
12
12
 
13
- try {
14
- await mkdir(HISTORY_DIR, { recursive: true });
15
-
16
- try {
17
- execSync(`curl -fsSL "${PAGES_URL}" -o "${HISTORY_FILE}"`, {
18
- stdio: "pipe",
19
- });
20
- console.log("✓ Restored benchmark history from Pages");
21
- } catch {
22
- console.log("No benchmark history on Pages; starting from empty history");
23
- writeFileSync(
24
- HISTORY_FILE,
25
- JSON.stringify(
26
- {
27
- runs: {},
28
- },
29
- null,
30
- 2,
31
- ),
32
- );
33
- }
34
- } catch (error) {
35
- console.error("Error restoring benchmark history:", error.message);
13
+ await mkdir(HISTORY_DIR, { recursive: true });
14
+ const response = await fetch(`${PAGES_URL}/data/bench-history.json`);
15
+ if (response.status === 404) {
16
+ console.log("No benchmark history on Pages; starting from empty history");
17
+ await writeFile(HISTORY_FILE, `${JSON.stringify({ version: 2, contexts: {} }, null, 2)}\n`);
18
+ } else if (!response.ok) {
19
+ console.error(`Failed to restore benchmark history: HTTP ${response.status}`);
36
20
  process.exit(1);
21
+ } else {
22
+ await writeFile(HISTORY_FILE, await response.text());
23
+ console.log("✓ Restored benchmark history from Pages");
37
24
  }
@@ -0,0 +1,251 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ appendRun,
4
+ assertValid,
5
+ compare,
6
+ contextId,
7
+ findRegressions,
8
+ formatComparison,
9
+ fromVitest,
10
+ getContext,
11
+ normalize,
12
+ parseResults,
13
+ previousRun,
14
+ RESULT_PREFIX,
15
+ } from "../../src/bench/history.mjs";
16
+
17
+ const fakeOs = {
18
+ cpus: () => [{ model: " Test CPU " }, { model: "Test CPU" }],
19
+ platform: () => "linux",
20
+ arch: () => "x64",
21
+ totalmem: () => 16 * 1024 ** 3,
22
+ };
23
+ const ctx = getContext(
24
+ fakeOs,
25
+ { BENCH_ENV: "gh-actions" },
26
+ "v24.1.0",
27
+ new Date("2026-10-20T03:17:00Z"),
28
+ );
29
+
30
+ const result = (id, median, extra = {}) => ({
31
+ id,
32
+ label: id,
33
+ unit: "ms",
34
+ median,
35
+ p90: median * 1.2,
36
+ min: median * 0.9,
37
+ max: median * 1.3,
38
+ samples: 5,
39
+ ...extra,
40
+ });
41
+
42
+ const vitestOutput = {
43
+ files: [
44
+ {
45
+ groups: [
46
+ {
47
+ benchmarks: [
48
+ {
49
+ name: "forge build es6 + types (18 files)",
50
+ mean: 70,
51
+ median: 64,
52
+ min: 60,
53
+ max: 80,
54
+ sampleCount: 5,
55
+ },
56
+ {
57
+ name: "forge build es6 only (18 files)",
58
+ mean: 10,
59
+ min: 8,
60
+ max: 12,
61
+ sampleCount: 5,
62
+ },
63
+ ],
64
+ },
65
+ ],
66
+ },
67
+ ],
68
+ };
69
+
70
+ describe("getContext / contextId", () => {
71
+ it("describes the machine and names the CI environment with the Node major", () => {
72
+ expect(ctx).toEqual({
73
+ benchEnv: "gh-actions-node24",
74
+ date: "2026-10-20T03:17:00.000Z",
75
+ os: { platform: "linux", arch: "x64" },
76
+ cpu: { model: "Test CPU", count: 2 },
77
+ memoryGb: 16,
78
+ node: "v24.1.0",
79
+ });
80
+ expect(getContext(fakeOs, {}, "v24.1.0").benchEnv).toBe("(local)");
81
+ });
82
+
83
+ it("gives the same id to the same machine regardless of date or env label", () => {
84
+ const later = getContext(fakeOs, {}, "v24.1.0", new Date("2027-01-01"));
85
+ expect(contextId(later)).toBe(contextId(ctx));
86
+ expect(contextId({ ...ctx, cpu: { ...ctx.cpu, count: 4 } })).not.toBe(contextId(ctx));
87
+ });
88
+ });
89
+
90
+ describe("parseResults", () => {
91
+ it("reads the last result line among other output", () => {
92
+ const results = [result("flux.load.cold", 250)];
93
+ const output = `flux load: median 250 ms\n${RESULT_PREFIX}[]\n${RESULT_PREFIX}${JSON.stringify(results)}\n`;
94
+ expect(parseResults(output, "flux-load")).toEqual(results);
95
+ });
96
+
97
+ it("fails, naming the suite, when there is no result line", () => {
98
+ expect(() => parseResults("crashed\n", "flux-serve")).toThrow(/flux-serve/);
99
+ });
100
+ });
101
+
102
+ describe("fromVitest", () => {
103
+ it("maps forge benchmarks to stable ids, keeping the file count out of the label", () => {
104
+ expect(fromVitest(vitestOutput)).toEqual([
105
+ {
106
+ id: "forge.build.local",
107
+ label: "forge build es6 + types",
108
+ unit: "ms",
109
+ median: 64,
110
+ min: 60,
111
+ max: 80,
112
+ samples: 5,
113
+ files: 18,
114
+ },
115
+ // No median from vitest: the mean stands in.
116
+ expect.objectContaining({ id: "forge.build.debug", median: 10, files: 18 }),
117
+ ]);
118
+ });
119
+
120
+ it("fails on a benchmark it has no id for, instead of charting it under a moving name", () => {
121
+ const output = {
122
+ files: [{ groups: [{ benchmarks: [{ name: "something new", mean: 1 }] }] }],
123
+ };
124
+ expect(() => fromVitest(output)).toThrow(/something new/);
125
+ });
126
+ });
127
+
128
+ describe("assertValid", () => {
129
+ it("passes real results and rejects NaN or empty ones, naming them", () => {
130
+ expect(() => assertValid([result("a", 1)])).not.toThrow();
131
+ expect(() =>
132
+ assertValid([result("a", 1), result("b", Number.NaN), result("c", 2, { samples: 0 })]),
133
+ ).toThrow(/b, c/);
134
+ });
135
+ });
136
+
137
+ describe("normalize", () => {
138
+ it("keeps a version 2 history as it is", () => {
139
+ const history = { version: 2, contexts: { x: { context: ctx, runs: [] } } };
140
+ expect(normalize(history)).toBe(history);
141
+ });
142
+
143
+ it("converts version 1 forge runs so their trend carries on", () => {
144
+ const v1 = {
145
+ runs: {
146
+ abc: {
147
+ context: ctx,
148
+ runs: [{ date: "2026-08-12T14:18:10Z", context: ctx, vitestOutput }],
149
+ },
150
+ empty: { context: ctx, runs: [] },
151
+ },
152
+ };
153
+ const history = normalize(v1);
154
+ expect(Object.keys(history.contexts)).toEqual(["abc"]);
155
+ expect(history.contexts.abc.runs[0]).toMatchObject({
156
+ date: "2026-08-12T14:18:10Z",
157
+ sha: null,
158
+ results: [{ id: "forge.build.local" }, { id: "forge.build.debug" }],
159
+ });
160
+ });
161
+
162
+ it("starts empty when there is no history", () => {
163
+ expect(normalize(null)).toEqual({ version: 2, contexts: {} });
164
+ });
165
+ });
166
+
167
+ describe("appendRun / previousRun", () => {
168
+ const run = (day) => ({
169
+ date: `2026-10-${day}T03:17:00Z`,
170
+ sha: `sha${day}`,
171
+ results: [result("a", Number(day))],
172
+ });
173
+
174
+ it("puts the new run first, trims to the limit, and leaves the input untouched", () => {
175
+ const empty = normalize(null);
176
+ const one = appendRun(empty, ctx, run("18"), 2);
177
+ const two = appendRun(one, ctx, run("19"), 2);
178
+ const three = appendRun(two, ctx, run("20"), 2);
179
+
180
+ expect(empty.contexts).toEqual({});
181
+ expect(one.contexts[contextId(ctx)].runs).toHaveLength(1);
182
+ expect(three.contexts[contextId(ctx)].runs.map((r) => r.sha)).toEqual(["sha20", "sha19"]);
183
+ expect(previousRun(three, contextId(ctx))?.sha).toBe("sha20");
184
+ expect(previousRun(three, "other-machine")).toBeUndefined();
185
+ });
186
+ });
187
+
188
+ describe("compare / formatComparison", () => {
189
+ it("reports the change in median against the previous run, and n/a for new benchmarks", () => {
190
+ const rows = compare([result("a", 120), result("new", 5)], {
191
+ results: [result("a", 100)],
192
+ });
193
+ expect(rows).toEqual([
194
+ { id: "a", current: 120, previous: 100, delta: 20, percent: 20 },
195
+ { id: "new", current: 5 },
196
+ ]);
197
+
198
+ const table = formatComparison(rows).split("\n");
199
+ expect(table[0]).toMatch(/^Benchmark \| +Median \| Previous \| +Delta \| +%$/);
200
+ expect(table[2]).toMatch(/^a +\| +120\.0 ms \| +100\.0 ms \| +\+20\.0 ms \| \+20\.0%$/);
201
+ expect(table[3]).toMatch(/^new +\| +5\.0 ms \| +n\/a \| +n\/a \| +n\/a$/);
202
+ });
203
+
204
+ it("compares against nothing on a machine's first run", () => {
205
+ expect(compare([result("a", 1)], undefined)).toEqual([{ id: "a", current: 1 }]);
206
+ });
207
+ });
208
+
209
+ describe("findRegressions", () => {
210
+ // Newest first, like the history: one run per night with this median for "a".
211
+ const nights = (...medians) =>
212
+ medians.map((median, index) => ({
213
+ date: `2026-10-${String(30 - index).padStart(2, "0")}T03:17:00Z`,
214
+ sha: `sha${30 - index}`,
215
+ results: [result("a", median)],
216
+ }));
217
+
218
+ it("flags a benchmark over the threshold on the last two nights, against the runs before", () => {
219
+ const [regression] = findRegressions(nights(130, 125, 100, 101, 99, 100, 102));
220
+ expect(regression).toMatchObject({
221
+ id: "a",
222
+ baseline: 100,
223
+ recent: [
224
+ { sha: "sha30", median: 130 },
225
+ { sha: "sha29", median: 125 },
226
+ ],
227
+ lastGood: { sha: "sha28" },
228
+ });
229
+ expect(regression.percent).toBeCloseTo(30);
230
+ });
231
+
232
+ it("ignores one slow night, which is as likely the runner as the code", () => {
233
+ expect(findRegressions(nights(150, 100, 100, 101, 99, 100))).toEqual([]);
234
+ });
235
+
236
+ it("ignores slowdowns within the threshold", () => {
237
+ expect(findRegressions(nights(115, 118, 100, 100, 100))).toEqual([]);
238
+ });
239
+
240
+ it("waits for enough history to have a baseline", () => {
241
+ expect(findRegressions(nights(200, 200, 100, 100))).toEqual([]);
242
+ expect(findRegressions(nights(200))).toEqual([]);
243
+ });
244
+
245
+ it("uses only the baselineRuns runs before the slow ones", () => {
246
+ // Old runs at 300 would hide the regression if they were in the baseline.
247
+ const runs = nights(130, 130, 100, 100, 100, 300, 300, 300, 300, 300);
248
+ const options = { threshold: 0.2, confirmRuns: 2, baselineRuns: 3, minBaseline: 3 };
249
+ expect(findRegressions(runs, options).map((r) => r.baseline)).toEqual([100]);
250
+ });
251
+ });