@decohere/ci-tools 0.0.1-dev.19 → 0.0.1-dev.20261105.103

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.
Files changed (43) hide show
  1. package/LICENSE +15 -0
  2. package/package.json +17 -3
  3. package/src/audit/enforce.mjs +7 -5
  4. package/src/audit/format.mjs +133 -0
  5. package/src/audit/summarize.mjs +10 -32
  6. package/src/audit/upsert-issue.mjs +25 -74
  7. package/src/bench/bench.mjs +135 -368
  8. package/src/bench/file-regressions.mjs +76 -0
  9. package/src/bench/history.mjs +248 -0
  10. package/src/bench/nightly.mjs +118 -0
  11. package/src/bench/regression-issue.mjs +44 -0
  12. package/src/bench/trace.mjs +18 -14
  13. package/src/github/first-publish-check.mjs +70 -0
  14. package/src/github/npm-bootstrap.mjs +182 -0
  15. package/src/github/npm-trust-promote.mjs +139 -0
  16. package/src/github/publish-verify.mjs +204 -0
  17. package/src/github/smoke-create-launcher.mjs +85 -0
  18. package/src/lib/github-issue.mjs +75 -0
  19. package/src/lib/lcov.mjs +1 -1
  20. package/src/lib/workspace-packages.mjs +30 -0
  21. package/src/pages/artifact.mjs +38 -183
  22. package/src/pages/bench-page.mjs +324 -0
  23. package/src/pages/restore-badges.mjs +35 -0
  24. package/src/pages/restore-bench-history.mjs +15 -28
  25. package/src/release/npm-links.mjs +6 -13
  26. package/src/release/promote-latest.mjs +122 -0
  27. package/src/release/publish-to-temporary-registry.mjs +65 -0
  28. package/src/release/sync-dependencies.mjs +1 -2
  29. package/tests/audit/format.test.mjs +115 -0
  30. package/tests/bench/history.test.mjs +279 -0
  31. package/tests/bench/regression-issue.test.mjs +42 -0
  32. package/tests/github/ci-workflow.test.mjs +30 -0
  33. package/tests/github/first-publish-check.test.mjs +51 -0
  34. package/tests/github/npm-bootstrap.test.mjs +125 -0
  35. package/tests/github/package-metadata.test.mjs +69 -0
  36. package/tests/github/publish-verify.test.mjs +115 -0
  37. package/tests/github/smoke-create-launcher.test.mjs +132 -0
  38. package/tests/lib/github-issue.test.mjs +85 -0
  39. package/tests/lib/lcov.test.mjs +8 -8
  40. package/tests/pages/bench-page.test.mjs +123 -0
  41. package/src/coverage/enforce-thresholds.mjs +0 -144
  42. package/src/pages/clear-bench-history.mjs +0 -27
  43. package/src/pages/keep-prior-bench-badge.mjs +0 -38
@@ -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
  }
@@ -4,8 +4,9 @@
4
4
  * Outputs markdown formatted links for GitHub release notes
5
5
  */
6
6
 
7
- import { readdirSync, readFileSync } from "node:fs";
7
+ import { readFileSync } from "node:fs";
8
8
  import { join } from "node:path";
9
+ import { listProjectDirs } from "../lib/workspace-packages.mjs";
9
10
 
10
11
  const VERSION = process.argv[2] || process.env.VERSION;
11
12
 
@@ -15,20 +16,12 @@ if (!VERSION) {
15
16
  }
16
17
 
17
18
  let links = "## Published Packages\n\n";
18
- const projectsDir = join(process.cwd(), "projects");
19
19
 
20
20
  try {
21
- const projects = readdirSync(projectsDir);
22
-
23
- for (const project of projects) {
24
- const pkgPath = join(projectsDir, project, "package.json");
25
- try {
26
- const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
27
- if (pkg.name?.startsWith("@decohere/")) {
28
- links += `- [\`${pkg.name}@${VERSION}\`](https://www.npmjs.com/package/${pkg.name}/v/${VERSION})\n`;
29
- }
30
- } catch {
31
- // Skip projects without package.json
21
+ for (const projectDir of listProjectDirs(process.cwd())) {
22
+ const pkg = JSON.parse(readFileSync(join(projectDir, "package.json"), "utf8"));
23
+ if (pkg.name?.startsWith("@decohere/")) {
24
+ links += `- [\`${pkg.name}@${VERSION}\`](https://www.npmjs.com/package/${pkg.name}/v/${VERSION})\n`;
32
25
  }
33
26
  }
34
27
 
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Points npm's `latest` tag at a version on every public @decohere package. Run by
4
+ * `promote-latest.yml`, which authenticates over OIDC.
5
+ *
6
+ * Refuses a version that isn't on every package, has no `v*` tag (a version only gets one once it
7
+ * passed CI and fully shipped), or is under a day old, unless `allowYoung` is set: current pnpm
8
+ * skips versions that new and would install an older one. Safe to re-run: packages already there
9
+ * are skipped.
10
+ */
11
+ import { spawnSync } from "node:child_process";
12
+ import { resolve } from "node:path";
13
+ import { pathToFileURL } from "node:url";
14
+ import { listPublishablePackages } from "../github/publish-verify.mjs";
15
+
16
+ const ONE_DAY_MS = 24 * 60 * 60 * 1000;
17
+ const LAUNCH_PACKAGE = "@decohere/cli";
18
+
19
+ /** @typedef {{ status: number | null, stdout: string }} RunResult */
20
+
21
+ /**
22
+ * @param {{
23
+ * version?: string,
24
+ * allowYoung?: boolean,
25
+ * packages: string[],
26
+ * run: (command: string, args: string[]) => RunResult,
27
+ * now?: () => number,
28
+ * log?: (message: string) => void,
29
+ * }} options
30
+ * @returns {number} exit code
31
+ */
32
+ export function promoteLatest(options) {
33
+ const { packages, run, allowYoung = false } = options;
34
+ const now = options.now ?? Date.now;
35
+ const log = options.log ?? console.log;
36
+ const npm = (...args) => run("npm", args);
37
+
38
+ const version =
39
+ options.version?.trim() || npm("view", LAUNCH_PACKAGE, "dist-tags.dev").stdout.trim();
40
+ if (!version) {
41
+ log(`Could not read the dev tag of ${LAUNCH_PACKAGE}. Nothing moved.`);
42
+ return 1;
43
+ }
44
+ log(`Promoting ${version} to latest on ${packages.length} packages`);
45
+
46
+ const tag = run("git", ["tag", "--list", `v${version}`]).stdout.trim();
47
+ if (tag !== `v${version}`) {
48
+ log(`${version} has no v${version} tag, so it didn't fully ship. Nothing moved.`);
49
+ return 1;
50
+ }
51
+
52
+ const missing = packages.filter(
53
+ (name) => npm("view", `${name}@${version}`, "version").stdout.trim() !== version,
54
+ );
55
+ if (missing.length > 0) {
56
+ log(`Not on npm at ${version}: ${missing.join(", ")}. Nothing moved.`);
57
+ return 1;
58
+ }
59
+
60
+ if (!allowYoung) {
61
+ const published = Date.parse(
62
+ npm("view", `${LAUNCH_PACKAGE}@${version}`, "time", "--json").stdout.match(
63
+ new RegExp(`"${version.replaceAll(".", "\\.")}":\\s*"([^"]+)"`),
64
+ )?.[1] ?? "",
65
+ );
66
+ if (Number.isNaN(published)) {
67
+ log(`Could not read when ${version} was published. Nothing moved.`);
68
+ return 1;
69
+ }
70
+ const hours = (now() - published) / (60 * 60 * 1000);
71
+ if (now() - published < ONE_DAY_MS) {
72
+ log(
73
+ `${version} is ${hours.toFixed(1)} hours old. Current pnpm skips versions under a day old ` +
74
+ "and would install an older one. Wait, or run with allow_young. Nothing moved.",
75
+ );
76
+ return 1;
77
+ }
78
+ }
79
+
80
+ /** `npm dist-tag ls` reads fresh; `npm view` can lag for a minute or so after a move. */
81
+ const onLatest = (name) => {
82
+ const line = npm("dist-tag", "ls", name)
83
+ .stdout.split("\n")
84
+ .find((entry) => entry.startsWith("latest:"));
85
+ return line?.slice("latest:".length).trim() === version;
86
+ };
87
+
88
+ const failed = [];
89
+ for (const name of packages) {
90
+ if (onLatest(name)) {
91
+ log(`already there: ${name}`);
92
+ continue;
93
+ }
94
+ if (npm("dist-tag", "add", `${name}@${version}`, "latest").status !== 0) failed.push(name);
95
+ }
96
+
97
+ const wrong = packages.filter((name) => !failed.includes(name) && !onLatest(name));
98
+ if (failed.length > 0 || wrong.length > 0) {
99
+ log(`Failed to move: ${[...failed, ...wrong].join(", ")}. Run it again to finish.`);
100
+ return 1;
101
+ }
102
+ log(`latest is now ${version} on all ${packages.length} packages`);
103
+ return 0;
104
+ }
105
+
106
+ if (process.argv[1] != null && import.meta.url === pathToFileURL(process.argv[1]).href) {
107
+ const packages = listPublishablePackages(resolve(".")).map((pkg) => pkg.name);
108
+ process.exit(
109
+ promoteLatest({
110
+ version: process.env.PROMOTE_VERSION,
111
+ allowYoung: process.env.PROMOTE_ALLOW_YOUNG === "true",
112
+ packages,
113
+ run: (command, args) => {
114
+ const result = spawnSync(command, args, {
115
+ encoding: "utf8",
116
+ stdio: ["ignore", "pipe", "inherit"],
117
+ });
118
+ return { status: result.status, stdout: result.stdout ?? "" };
119
+ },
120
+ }),
121
+ );
122
+ }
@@ -0,0 +1,65 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Publishes every public @decohere package in a build folder to CI's temporary registry, named by
4
+ * DECOHERE_E2E_REGISTRY. Plain `npm publish`: `forge publish` ignores the user's npm config on
5
+ * purpose, so that a real publish can't pick up stray credentials. npm refuses to publish without
6
+ * a login, so this gives it a made-up one; the registry takes anything.
7
+ *
8
+ * Usage: publish-to-temporary-registry.mjs <build folder>
9
+ */
10
+ import { spawnSync } from "node:child_process";
11
+ import { mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join, resolve } from "node:path";
14
+ import { pathToFileURL } from "node:url";
15
+
16
+ /**
17
+ * @param {string} dir
18
+ * @returns {string[]} folders of public @decohere packages
19
+ */
20
+ export function findPublicPackages(dir) {
21
+ const found = [];
22
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
23
+ if (entry.name === "node_modules") continue;
24
+ const path = join(dir, entry.name);
25
+ if (entry.isDirectory()) {
26
+ found.push(...findPublicPackages(path));
27
+ } else if (entry.name === "package.json") {
28
+ const pkg = JSON.parse(readFileSync(path, "utf8"));
29
+ if (pkg.name?.startsWith("@decohere/") && pkg.private !== true) found.push(dir);
30
+ }
31
+ }
32
+ return found;
33
+ }
34
+
35
+ if (process.argv[1] != null && import.meta.url === pathToFileURL(process.argv[1]).href) {
36
+ const registry = process.env.DECOHERE_E2E_REGISTRY;
37
+ if (!registry) {
38
+ console.error("DECOHERE_E2E_REGISTRY is required");
39
+ process.exit(1);
40
+ }
41
+ const { host } = new URL(registry);
42
+ const userConfig = join(mkdtempSync(join(tmpdir(), "temporary-registry-")), ".npmrc");
43
+ writeFileSync(userConfig, `registry=${registry}\n//${host}/:_authToken=made-up\n`);
44
+
45
+ const packages = findPublicPackages(resolve(process.argv[2] ?? "dist/release"));
46
+ if (packages.length === 0) {
47
+ console.error("No public @decohere packages found");
48
+ process.exit(1);
49
+ }
50
+ for (const dir of packages) {
51
+ const result = spawnSync("npm", ["publish", dir, "--tag", "dev"], {
52
+ stdio: ["ignore", "inherit", "inherit"],
53
+ env: {
54
+ ...process.env,
55
+ NPM_CONFIG_REGISTRY: registry,
56
+ NPM_CONFIG_USERCONFIG: userConfig,
57
+ },
58
+ });
59
+ if (result.status !== 0) {
60
+ console.error(`Publishing ${dir} failed`);
61
+ process.exit(result.status ?? 1);
62
+ }
63
+ }
64
+ console.log(`Published ${packages.length} packages`);
65
+ }
@@ -7,10 +7,9 @@
7
7
  import { execSync } from "node:child_process";
8
8
 
9
9
  const DRY_RUN = process.env.DRY_RUN === "true";
10
- const TERMINAL_CLI = process.env.TERMINAL_CLI || "dist/debug/projects/terminal/src/cli.js";
11
10
  const DECOHERE_CONFIG = process.env.DECOHERE_CONFIG || ".decohere.js";
12
11
 
13
- const baseCmd = `node "${TERMINAL_CLI}" --log-level trace --config-file "${DECOHERE_CONFIG}" forge dependencies --configuration release sync`;
12
+ const baseCmd = `pnpm forge --log-level info --config-file "${DECOHERE_CONFIG}" dependencies --configuration release sync`;
14
13
 
15
14
  try {
16
15
  if (DRY_RUN) {
@@ -0,0 +1,115 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ formatLog,
4
+ formatMarkdown,
5
+ highCritical,
6
+ parseAudit,
7
+ } from "../../src/audit/format.mjs";
8
+
9
+ const fixture = {
10
+ metadata: {
11
+ vulnerabilities: { info: 0, low: 1, moderate: 0, high: 1, critical: 0, total: 2 },
12
+ },
13
+ advisories: {
14
+ 1: {
15
+ id: 1,
16
+ module_name: "nanoid",
17
+ severity: "high",
18
+ title: "nanoid: custom generators can loop indefinitely when size is zero",
19
+ url: "https://github.com/advisories/GHSA-2v37-7h3g-55p8",
20
+ vulnerable_versions: "<3.3.18",
21
+ patched_versions: ">=3.3.18",
22
+ cves: [],
23
+ findings: [{ version: "3.3.17", paths: [". > vite > postcss > nanoid@3.3.17"] }],
24
+ },
25
+ 2: {
26
+ id: 2,
27
+ module_name: "esbuild",
28
+ severity: "low",
29
+ title: "esbuild Windows file read",
30
+ url: "https://github.com/advisories/GHSA-g7r4-m6w7-qqqr",
31
+ vulnerable_versions: "<0.28.1",
32
+ patched_versions: ">=0.28.1",
33
+ cves: [],
34
+ findings: [{ version: "0.27.3", paths: [". > vite > esbuild"] }],
35
+ },
36
+ },
37
+ };
38
+
39
+ describe("parseAudit", () => {
40
+ it("reads severity counts and sorts advisories critical/high first", () => {
41
+ const parsed = parseAudit(fixture);
42
+ expect(parsed.total).toBe(2);
43
+ expect(parsed.high).toBe(1);
44
+ expect(parsed.low).toBe(1);
45
+ expect(parsed.advisories.map((a) => a.module)).toEqual(["nanoid", "esbuild"]);
46
+ expect(parsed.advisories[0]?.severity).toBe("high");
47
+ expect(parsed.advisories[0]?.ghsa).toBe("GHSA-2v37-7h3g-55p8");
48
+ });
49
+ });
50
+
51
+ describe("formatLog", () => {
52
+ it("prints severity counts and advisory lines including GHSA", () => {
53
+ const log = formatLog(parseAudit(fixture));
54
+ expect(log).toContain("high=1");
55
+ expect(log).toContain("[high] nanoid GHSA-2v37-7h3g-55p8");
56
+ expect(log).toContain("[low] esbuild");
57
+ });
58
+ });
59
+
60
+ describe("formatMarkdown", () => {
61
+ it("includes a severity table and linked advisories", () => {
62
+ const md = formatMarkdown(parseAudit(fixture));
63
+ expect(md).toContain("| High | 1 |");
64
+ expect(md).toContain("**high** `nanoid`");
65
+ expect(md).toContain("GHSA-2v37-7h3g-55p8");
66
+ });
67
+ });
68
+
69
+ describe("highCritical", () => {
70
+ it("filters to high and critical only", () => {
71
+ expect(highCritical(parseAudit(fixture)).map((a) => a.module)).toEqual(["nanoid"]);
72
+ });
73
+ });
74
+
75
+ describe("parseAudit npm v2", () => {
76
+ it("dedupes GHSA rows from package via objects", () => {
77
+ const v2 = {
78
+ auditReportVersion: 2,
79
+ metadata: {
80
+ vulnerabilities: { info: 0, low: 0, moderate: 0, high: 1, critical: 0, total: 1 },
81
+ },
82
+ vulnerabilities: {
83
+ nanoid: {
84
+ name: "nanoid",
85
+ severity: "high",
86
+ via: [
87
+ {
88
+ source: 1103848,
89
+ name: "nanoid",
90
+ title: "nanoid infinite loop",
91
+ url: "https://github.com/advisories/GHSA-2v37-7h3g-55p8",
92
+ severity: "high",
93
+ range: "<3.3.18",
94
+ },
95
+ ],
96
+ range: "<3.3.18",
97
+ nodes: ["node_modules/.pnpm/nanoid@3.3.17/node_modules/nanoid"],
98
+ },
99
+ postcss: {
100
+ name: "postcss",
101
+ severity: "high",
102
+ via: ["nanoid"],
103
+ effects: [],
104
+ range: "*",
105
+ nodes: ["node_modules/.pnpm/postcss@8.5.26/node_modules/postcss"],
106
+ },
107
+ },
108
+ };
109
+ const parsed = parseAudit(v2);
110
+ expect(parsed.advisories).toHaveLength(1);
111
+ expect(parsed.advisories[0]?.module).toBe("nanoid");
112
+ expect(parsed.advisories[0]?.ghsa).toBe("GHSA-2v37-7h3g-55p8");
113
+ expect(parsed.advisories[0]?.vulnerable).toBe("<3.3.18");
114
+ });
115
+ });
@@ -0,0 +1,279 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ appendRun,
4
+ assertValid,
5
+ compare,
6
+ contextId,
7
+ compareRounds,
8
+ latestRun,
9
+ pruneStale,
10
+ formatComparison,
11
+ fromVitest,
12
+ getContext,
13
+ normalize,
14
+ parseResults,
15
+ previousRun,
16
+ RESULT_PREFIX,
17
+ } from "../../src/bench/history.mjs";
18
+
19
+ const fakeOs = {
20
+ cpus: () => [{ model: " Test CPU " }, { model: "Test CPU" }],
21
+ platform: () => "linux",
22
+ arch: () => "x64",
23
+ totalmem: () => 16 * 1024 ** 3,
24
+ };
25
+ const ctx = getContext(
26
+ fakeOs,
27
+ { BENCH_ENV: "gh-actions" },
28
+ "v24.1.0",
29
+ new Date("2026-10-20T03:17:00Z"),
30
+ );
31
+
32
+ const result = (id, median, extra = {}) => ({
33
+ id,
34
+ label: id,
35
+ unit: "ms",
36
+ median,
37
+ p90: median * 1.2,
38
+ min: median * 0.9,
39
+ max: median * 1.3,
40
+ samples: 5,
41
+ ...extra,
42
+ });
43
+
44
+ const vitestOutput = {
45
+ files: [
46
+ {
47
+ groups: [
48
+ {
49
+ benchmarks: [
50
+ {
51
+ name: "forge build es6 + types (18 files)",
52
+ mean: 70,
53
+ median: 64,
54
+ min: 60,
55
+ max: 80,
56
+ sampleCount: 5,
57
+ },
58
+ {
59
+ name: "forge build es6 only (18 files)",
60
+ mean: 10,
61
+ min: 8,
62
+ max: 12,
63
+ sampleCount: 5,
64
+ },
65
+ ],
66
+ },
67
+ ],
68
+ },
69
+ ],
70
+ };
71
+
72
+ describe("getContext / contextId", () => {
73
+ it("describes the machine and names the CI environment with the Node major", () => {
74
+ expect(ctx).toEqual({
75
+ benchEnv: "gh-actions-node24",
76
+ date: "2026-10-20T03:17:00.000Z",
77
+ os: { platform: "linux", arch: "x64" },
78
+ cpu: { model: "Test CPU", count: 2 },
79
+ memoryGb: 16,
80
+ node: "v24.1.0",
81
+ });
82
+ expect(getContext(fakeOs, {}, "v24.1.0").benchEnv).toBe("(local)");
83
+ });
84
+
85
+ it("gives the same id to the same machine regardless of date or env label", () => {
86
+ const later = getContext(fakeOs, {}, "v24.1.0", new Date("2027-01-01"));
87
+ expect(contextId(later)).toBe(contextId(ctx));
88
+ expect(contextId({ ...ctx, cpu: { ...ctx.cpu, count: 4 } })).not.toBe(contextId(ctx));
89
+ });
90
+ });
91
+
92
+ describe("parseResults", () => {
93
+ it("reads the last result line among other output", () => {
94
+ const results = [result("flux.load.cold", 250)];
95
+ const output = `flux load: median 250 ms\n${RESULT_PREFIX}[]\n${RESULT_PREFIX}${JSON.stringify(results)}\n`;
96
+ expect(parseResults(output, "flux-load")).toEqual(results);
97
+ });
98
+
99
+ it("fails, naming the suite, when there is no result line", () => {
100
+ expect(() => parseResults("crashed\n", "flux-serve")).toThrow(/flux-serve/);
101
+ });
102
+ });
103
+
104
+ describe("fromVitest", () => {
105
+ it("maps forge benchmarks to stable ids, keeping the file count out of the label", () => {
106
+ expect(fromVitest(vitestOutput)).toEqual([
107
+ {
108
+ id: "forge.build.local",
109
+ label: "forge build es6 + types",
110
+ unit: "ms",
111
+ median: 64,
112
+ min: 60,
113
+ max: 80,
114
+ samples: 5,
115
+ files: 18,
116
+ },
117
+ // No median from vitest: the mean stands in.
118
+ expect.objectContaining({ id: "forge.build.debug", median: 10, files: 18 }),
119
+ ]);
120
+ });
121
+
122
+ it("fails on a benchmark it has no id for, instead of charting it under a moving name", () => {
123
+ const output = {
124
+ files: [{ groups: [{ benchmarks: [{ name: "something new", mean: 1 }] }] }],
125
+ };
126
+ expect(() => fromVitest(output)).toThrow(/something new/);
127
+ });
128
+ });
129
+
130
+ describe("assertValid", () => {
131
+ it("passes real results and rejects NaN or empty ones, naming them", () => {
132
+ expect(() => assertValid([result("a", 1)])).not.toThrow();
133
+ expect(() =>
134
+ assertValid([result("a", 1), result("b", Number.NaN), result("c", 2, { samples: 0 })]),
135
+ ).toThrow(/b, c/);
136
+ });
137
+ });
138
+
139
+ describe("normalize", () => {
140
+ it("keeps a version 2 history as it is", () => {
141
+ const history = { version: 2, contexts: { x: { context: ctx, runs: [] } } };
142
+ expect(normalize(history)).toBe(history);
143
+ });
144
+
145
+ it("converts version 1 forge runs so their trend carries on", () => {
146
+ const v1 = {
147
+ runs: {
148
+ abc: {
149
+ context: ctx,
150
+ runs: [{ date: "2026-08-12T14:18:10Z", context: ctx, vitestOutput }],
151
+ },
152
+ empty: { context: ctx, runs: [] },
153
+ },
154
+ };
155
+ const history = normalize(v1);
156
+ expect(Object.keys(history.contexts)).toEqual(["abc"]);
157
+ expect(history.contexts.abc.runs[0]).toMatchObject({
158
+ date: "2026-08-12T14:18:10Z",
159
+ sha: null,
160
+ results: [{ id: "forge.build.local" }, { id: "forge.build.debug" }],
161
+ });
162
+ });
163
+
164
+ it("starts empty when there is no history", () => {
165
+ expect(normalize(null)).toEqual({ version: 2, contexts: {} });
166
+ });
167
+ });
168
+
169
+ describe("appendRun / previousRun", () => {
170
+ const run = (day) => ({
171
+ date: `2026-10-${day}T03:17:00Z`,
172
+ sha: `sha${day}`,
173
+ results: [result("a", Number(day))],
174
+ });
175
+
176
+ it("puts the new run first, trims to the limit, and leaves the input untouched", () => {
177
+ const empty = normalize(null);
178
+ const one = appendRun(empty, ctx, run("18"), 2);
179
+ const two = appendRun(one, ctx, run("19"), 2);
180
+ const three = appendRun(two, ctx, run("20"), 2);
181
+
182
+ expect(empty.contexts).toEqual({});
183
+ expect(one.contexts[contextId(ctx)].runs).toHaveLength(1);
184
+ expect(three.contexts[contextId(ctx)].runs.map((r) => r.sha)).toEqual(["sha20", "sha19"]);
185
+ expect(previousRun(three, contextId(ctx))?.sha).toBe("sha20");
186
+ expect(previousRun(three, "other-machine")).toBeUndefined();
187
+ });
188
+ });
189
+
190
+ describe("compare / formatComparison", () => {
191
+ it("reports the change in median against the previous run, and n/a for new benchmarks", () => {
192
+ const rows = compare([result("a", 120), result("new", 5)], {
193
+ results: [result("a", 100)],
194
+ });
195
+ expect(rows).toEqual([
196
+ { id: "a", current: 120, previous: 100, delta: 20, percent: 20 },
197
+ { id: "new", current: 5 },
198
+ ]);
199
+
200
+ const table = formatComparison(rows).split("\n");
201
+ expect(table[0]).toMatch(/^Benchmark \| +Median \| Previous \| +Delta \| +%$/);
202
+ expect(table[2]).toMatch(/^a +\| +120\.0 ms \| +100\.0 ms \| +\+20\.0 ms \| \+20\.0%$/);
203
+ expect(table[3]).toMatch(/^new +\| +5\.0 ms \| +n\/a \| +n\/a \| +n\/a$/);
204
+ });
205
+
206
+ it("compares against nothing on a machine's first run", () => {
207
+ expect(compare([result("a", 1)], undefined)).toEqual([{ id: "a", current: 1 }]);
208
+ });
209
+ });
210
+
211
+ describe("compareRounds", () => {
212
+ const round = (...medians) => medians.map((median, index) => result(`b${index}`, median));
213
+
214
+ it("flags a benchmark slower on head than base in every round, with both rounds' numbers", () => {
215
+ const [regression] = compareRounds([round(130, 50)], [round(100, 50)], {
216
+ threshold: 0.2,
217
+ rounds: 1,
218
+ });
219
+ expect(regression).toMatchObject({
220
+ id: "b0",
221
+ base: 100,
222
+ head: 130,
223
+ rounds: [{ base: 100, head: 130 }],
224
+ });
225
+ expect(regression.percent).toBeCloseTo(30);
226
+ });
227
+
228
+ it("needs every round slower: one slow round is as likely the runner as the code", () => {
229
+ expect(compareRounds([round(130), round(105)], [round(100), round(100)])).toEqual([]);
230
+ expect(compareRounds([round(130), round(126)], [round(100), round(100)])).toHaveLength(1);
231
+ });
232
+
233
+ it("ignores slowdowns within the threshold", () => {
234
+ expect(compareRounds([round(115), round(118)], [round(100), round(100)])).toEqual([]);
235
+ });
236
+
237
+ it("skips benchmarks only one side has, and compares nothing without enough rounds", () => {
238
+ const head = [
239
+ [result("new", 500), result("old", 100)],
240
+ [result("new", 500), result("old", 100)],
241
+ ];
242
+ const base = [[result("old", 100)], [result("old", 100)]];
243
+ expect(compareRounds(head, base)).toEqual([]);
244
+ expect(compareRounds([round(200)], [round(100)])).toEqual([]);
245
+ });
246
+ });
247
+
248
+ describe("pruneStale / latestRun", () => {
249
+ const at = (date, sha) => ({ date, sha, results: [] });
250
+ const history = {
251
+ version: 2,
252
+ contexts: {
253
+ old: { context: ctx, runs: [at("2026-08-12T03:17:00Z", "aaa")] },
254
+ recent: {
255
+ context: ctx,
256
+ runs: [at("2026-10-20T03:17:00Z", "ccc"), at("2026-10-19T03:17:00Z", "bbb")],
257
+ },
258
+ },
259
+ };
260
+
261
+ it("drops machine types with no run in the last 30 days", () => {
262
+ expect(
263
+ Object.keys(pruneStale(history, new Date("2026-10-21T00:00:00Z")).contexts),
264
+ ).toEqual(["recent"]);
265
+ expect(
266
+ Object.keys(pruneStale(history, new Date("2026-09-01T00:00:00Z")).contexts),
267
+ ).toEqual(["old", "recent"]);
268
+ });
269
+
270
+ it("prunes when a run is appended", () => {
271
+ const next = appendRun(history, ctx, at("2026-10-21T03:17:00Z", "ddd"));
272
+ expect(Object.keys(next.contexts)).not.toContain("old");
273
+ });
274
+
275
+ it("finds the newest run on any machine: the commit the previous nightly measured", () => {
276
+ expect(latestRun(history)?.sha).toBe("ccc");
277
+ expect(latestRun({ version: 2, contexts: {} })).toBeUndefined();
278
+ });
279
+ });