@decohere/ci-tools 0.0.1-dev.11 → 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,71 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { runCreateLauncherSmoke } from "../../src/github/smoke-create-launcher.mjs";
4
+
5
+ const fs = await vi.importActual("node:fs");
6
+ const PUBLISH_YML = fileURLToPath(
7
+ new URL("../../../../.github/workflows/publish.yml", import.meta.url),
8
+ );
9
+
10
+ describe("runCreateLauncherSmoke", () => {
11
+ it("execs npx from a temp directory, not the repo root", () => {
12
+ /** @type {{ cmd: string, args: string[], opts: { cwd?: string } }[]} */
13
+ const calls = [];
14
+ const written = [];
15
+
16
+ const result = runCreateLauncherSmoke({
17
+ createVersion: "0.0.1-dev.80",
18
+ authToken: "tok",
19
+ tmpdir: () => "/tmp",
20
+ mkdtempSync: (prefix) => `${prefix}abc`,
21
+ writeFileSync: (path, body) => {
22
+ written.push({ path, body });
23
+ },
24
+ spawnSync: (cmd, args, opts) => {
25
+ calls.push({ cmd, args, opts });
26
+ return { status: 0 };
27
+ },
28
+ stdio: "pipe",
29
+ });
30
+
31
+ expect(result.workDir).toBe("/tmp/decohere-create-smoke-abc");
32
+ expect(result.workDir).not.toBe(process.cwd());
33
+ expect(calls).toHaveLength(1);
34
+ expect(calls[0].cmd).toBe("npx");
35
+ expect(calls[0].args).toEqual(["--yes", "@decohere/create@0.0.1-dev.80", "--help"]);
36
+ expect(calls[0].opts.cwd).toBe("/tmp/decohere-create-smoke-abc");
37
+ expect(calls[0].opts.cwd).not.toBe(process.cwd());
38
+ expect(written).toEqual([
39
+ {
40
+ path: "/tmp/decohere-create-smoke-abc/.npmrc",
41
+ body: "@decohere:registry=https://registry.npmjs.org/\n//registry.npmjs.org/:_authToken=tok\n",
42
+ },
43
+ ]);
44
+ });
45
+
46
+ it("fails closed without version or token", () => {
47
+ expect(() => runCreateLauncherSmoke({ createVersion: "", authToken: "tok" })).toThrow(
48
+ "CREATE_VERSION is required",
49
+ );
50
+ expect(() =>
51
+ runCreateLauncherSmoke({ createVersion: "0.0.1-dev.80", authToken: "" }),
52
+ ).toThrow("NODE_AUTH_TOKEN is required");
53
+ });
54
+ });
55
+
56
+ describe("publish.yml create smoke + tag", () => {
57
+ const yml = fs.readFileSync(PUBLISH_YML, "utf8");
58
+
59
+ it("wires create smoke through the isolated launcher", () => {
60
+ expect(yml).toContain("node projects/ci-tools/src/github/smoke-create-launcher.mjs");
61
+ expect(yml).toContain("CREATE_VERSION:");
62
+ expect(yml).not.toContain("npx --yes");
63
+ });
64
+
65
+ it("grants the tag job actions:read so publish-prepare can download CI artifacts", () => {
66
+ const tag = yml.slice(yml.indexOf("\n tag:"));
67
+ const perms = tag.slice(tag.indexOf("permissions:"), tag.indexOf("steps:"));
68
+ expect(perms).toMatch(/contents:\s*write/);
69
+ expect(perms).toMatch(/actions:\s*read/);
70
+ });
71
+ });
@@ -0,0 +1,106 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { buildBenchmarkHtml, trendSvg } from "../../src/pages/bench-page.mjs";
3
+
4
+ const context = (benchEnv, cores) => ({
5
+ benchEnv,
6
+ os: { platform: "linux", arch: "x64" },
7
+ cpu: { model: "Test CPU", count: cores },
8
+ memoryGb: 16,
9
+ node: "v24.1.0",
10
+ });
11
+
12
+ const run = (date, sha, results) => ({ date, sha, results });
13
+ const result = (id, median, label = id) => ({
14
+ id,
15
+ label,
16
+ unit: "ms",
17
+ median,
18
+ p90: median * 1.2,
19
+ min: median,
20
+ max: median,
21
+ samples: 5,
22
+ });
23
+
24
+ const history = {
25
+ version: 2,
26
+ contexts: {
27
+ ci: {
28
+ context: context("gh-actions-node24", 4),
29
+ runs: [
30
+ run("2026-10-20T03:17:00Z", "bbbbbbbbbbbb", [
31
+ result("flux.serve.start", 90, "spawn <to> ready"),
32
+ result("forge.build.debug", 9),
33
+ ]),
34
+ run("2026-10-19T03:17:00Z", "aaaaaaaaaaaa", [
35
+ result("flux.serve.start", 100, "spawn <to> ready"),
36
+ ]),
37
+ ],
38
+ },
39
+ laptop: {
40
+ context: context("(local)", 24),
41
+ runs: [run("2026-10-01T10:00:00Z", "cccccccccccc", [result("flux.serve.start", 40)])],
42
+ },
43
+ },
44
+ };
45
+
46
+ describe("buildBenchmarkHtml", () => {
47
+ it("explains an empty history instead of rendering empty charts", () => {
48
+ const html = buildBenchmarkHtml({ version: 2, contexts: {} });
49
+ expect(html).toContain("No benchmark history yet");
50
+ expect(html).not.toContain("<svg");
51
+ });
52
+
53
+ it("draws one card per benchmark, grouped, with a line per machine that ran it", () => {
54
+ const html = buildBenchmarkHtml(history, { branch: "main", sha: "0123456789abcdef" });
55
+
56
+ expect(html.match(/<article class="card">/g)).toHaveLength(2);
57
+ expect(html.indexOf("flux serve (real process)")).toBeLessThan(
58
+ html.indexOf("<h2>forge build</h2>"),
59
+ );
60
+ const serveCard = html.slice(
61
+ html.indexOf("<code>flux.serve.start</code>"),
62
+ html.indexOf("<code>forge.build.debug</code>"),
63
+ );
64
+ expect(serveCard.match(/<polyline/g)).toHaveLength(2);
65
+ expect(html).toContain("built from main @ <code>0123456789ab</code>");
66
+ });
67
+
68
+ it("shows the latest median and the change from the previous run on the newest machine", () => {
69
+ const html = buildBenchmarkHtml(history);
70
+ expect(html).toContain("<strong>90.0 ms</strong> median · p90 108 ms");
71
+ expect(html).toContain('<span class="change better">10.0% faster</span>');
72
+ });
73
+
74
+ it("dates each result and links its commit, in the latest table and on each card", () => {
75
+ const html = buildBenchmarkHtml(history);
76
+ expect(html).toContain("<th>Run</th><th>Commit</th>");
77
+ expect(html).toContain("<td>2026-10-20 03:17 UTC</td>");
78
+ expect(html).toContain(
79
+ '<a href="https://github.com/typesmiths/decohere/commit/bbbbbbbbbbbb"><code>bbbbbbb</code></a>',
80
+ );
81
+ expect(html).toContain('<p class="when">Last run 2026-10-20 03:17 UTC</p>');
82
+ });
83
+
84
+ it("escapes labels from the history", () => {
85
+ const html = buildBenchmarkHtml(history);
86
+ expect(html).toContain("spawn &lt;to&gt; ready");
87
+ expect(html).not.toContain("spawn <to> ready");
88
+ });
89
+ });
90
+
91
+ describe("trendSvg", () => {
92
+ it("places a single point without dividing by a zero time range", () => {
93
+ const svg = trendSvg([
94
+ {
95
+ color: "red",
96
+ points: [{ time: 1, date: "2026-10-20", sha: "abc", median: 5, p90: 6 }],
97
+ },
98
+ ]);
99
+ expect(svg).toContain("<circle");
100
+ expect(svg).not.toContain("NaN");
101
+ });
102
+
103
+ it("draws nothing when no machine has a point", () => {
104
+ expect(trendSvg([])).toBe("");
105
+ });
106
+ });
@@ -1,144 +0,0 @@
1
- import { existsSync, readdirSync, readFileSync } from "node:fs";
2
- import { resolve } from "node:path";
3
- import { pathToFileURL } from "node:url";
4
- import { parseLcovByProject } from "../lib/lcov.mjs";
5
-
6
- function parseArgs(argv) {
7
- const options = {
8
- lcov: ".forge/coverage/lcov.info",
9
- thresholds: null,
10
- thresholdsModule: null,
11
- projectsDir: "projects",
12
- };
13
-
14
- for (let index = 0; index < argv.length; index += 1) {
15
- const arg = argv[index];
16
- if (arg === "--lcov") {
17
- options.lcov = argv[index + 1] ?? options.lcov;
18
- index += 1;
19
- continue;
20
- }
21
- if (arg === "--thresholds") {
22
- options.thresholds = argv[index + 1] ?? options.thresholds;
23
- index += 1;
24
- continue;
25
- }
26
- if (arg === "--thresholds-module") {
27
- options.thresholdsModule = argv[index + 1] ?? options.thresholdsModule;
28
- index += 1;
29
- continue;
30
- }
31
- if (arg === "--projectsDir") {
32
- options.projectsDir = argv[index + 1] ?? options.projectsDir;
33
- index += 1;
34
- }
35
- }
36
-
37
- return options;
38
- }
39
-
40
- async function resolveThresholds(options) {
41
- if (options.thresholdsModule) {
42
- const moduleUrl = pathToFileURL(resolve(options.thresholdsModule)).href;
43
- const mod = await import(moduleUrl);
44
- const thresholds = mod.projectCoverageThresholds;
45
- if (typeof thresholds !== "object" || thresholds == null) {
46
- console.error(
47
- `Module ${options.thresholdsModule} must export a 'projectCoverageThresholds' object.`,
48
- );
49
- process.exit(1);
50
- }
51
- return thresholds;
52
- }
53
-
54
- const path = options.thresholds ?? "coverage.config.json";
55
- if (!existsSync(path)) {
56
- console.error(`Thresholds config not found: ${path}`);
57
- process.exit(1);
58
- }
59
- const parsed = JSON.parse(readFileSync(path, "utf8"));
60
- if (typeof parsed !== "object" || parsed == null) {
61
- console.error(
62
- "Thresholds config must be a JSON object mapping project => minimum coverage percentage.",
63
- );
64
- process.exit(1);
65
- }
66
- return parsed;
67
- }
68
-
69
- function formatPct(value) {
70
- return `${value.toFixed(2)}%`;
71
- }
72
-
73
- async function main() {
74
- const options = parseArgs(process.argv.slice(2));
75
- const lcovPath = resolve(options.lcov);
76
- const projectsDirPath = resolve(options.projectsDir);
77
-
78
- if (!existsSync(lcovPath)) {
79
- console.error(`LCOV file not found: ${lcovPath}`);
80
- console.error("Run coverage first (example: npm run test:coverage)");
81
- process.exit(1);
82
- }
83
-
84
- if (!existsSync(projectsDirPath)) {
85
- console.error(`Projects directory not found: ${projectsDirPath}`);
86
- process.exit(1);
87
- }
88
-
89
- const thresholds = await resolveThresholds(options);
90
- const projectDirs = readdirSync(projectsDirPath, { withFileTypes: true })
91
- .filter((entry) => entry.isDirectory())
92
- .map((entry) => entry.name)
93
- .sort((left, right) => left.localeCompare(right));
94
-
95
- const coverageByProject = parseLcovByProject(readFileSync(lcovPath, "utf8"));
96
-
97
- const missingThresholds = projectDirs.filter(
98
- (projectName) => thresholds[projectName] == null,
99
- );
100
- if (missingThresholds.length > 0) {
101
- console.error("Missing coverage thresholds for projects:");
102
- for (const projectName of missingThresholds) console.error(`- ${projectName}`);
103
- console.error(
104
- "Update projectCoverageThresholds in vitest.config.ts to include all project directories.",
105
- );
106
- process.exit(1);
107
- }
108
-
109
- const failures = [];
110
- console.log("Project line coverage thresholds");
111
-
112
- for (const projectName of projectDirs) {
113
- const projectCoverage = coverageByProject.get(projectName) ?? {
114
- totalLines: 0,
115
- coveredLines: 0,
116
- };
117
- const currentPct =
118
- projectCoverage.totalLines === 0
119
- ? 0
120
- : (projectCoverage.coveredLines / projectCoverage.totalLines) * 100;
121
- const minimumPct = Number(thresholds[projectName]);
122
- const passed = Number.isFinite(minimumPct) && currentPct >= minimumPct;
123
-
124
- console.log(
125
- `${passed ? "✅" : "❌"} ${projectName}: ${formatPct(currentPct)} (min ${formatPct(minimumPct)})`,
126
- );
127
-
128
- if (!passed) failures.push({ projectName, currentPct, minimumPct });
129
- }
130
-
131
- if (failures.length > 0) {
132
- console.error("\nCoverage threshold check failed:");
133
- for (const failure of failures) {
134
- console.error(
135
- `- ${failure.projectName}: ${formatPct(failure.currentPct)} is below ${formatPct(failure.minimumPct)}`,
136
- );
137
- }
138
- process.exit(1);
139
- }
140
-
141
- console.log("\nAll project coverage thresholds passed.");
142
- }
143
-
144
- main();
@@ -1,27 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- // Clear benchmark history for this publish (workflow_dispatch with clear-history=true)
4
-
5
- import { writeFileSync } from "node:fs";
6
- import { mkdir } from "node:fs/promises";
7
-
8
- const HISTORY_DIR = "projects/ci-tools/dist/ci/bench";
9
- const HISTORY_FILE = `${HISTORY_DIR}/bench-history.json`;
10
-
11
- try {
12
- await mkdir(HISTORY_DIR, { recursive: true });
13
- writeFileSync(
14
- HISTORY_FILE,
15
- JSON.stringify(
16
- {
17
- runs: {},
18
- },
19
- null,
20
- 2,
21
- ),
22
- );
23
- console.log("✓ Cleared benchmark history");
24
- } catch (error) {
25
- console.error("Error clearing benchmark history:", error.message);
26
- process.exit(1);
27
- }
@@ -1,38 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- // Keep prior bench badge if no code changes; create fallback if none exists
4
-
5
- import { execSync } from "node:child_process";
6
- import { existsSync, writeFileSync } from "node:fs";
7
- import { mkdir } from "node:fs/promises";
8
-
9
- const BADGES_DIR = "projects/ci-tools/dist/ci/badges";
10
- const BADGE_FILE = `${BADGES_DIR}/bench.svg`;
11
- const PAGES_URL = "https://typesmiths.github.io/decohere/badges/bench.svg";
12
-
13
- const FALLBACK_BADGE = `<svg xmlns="http://www.w3.org/2000/svg" width="120" height="20" role="img" aria-label="bench: unavailable">
14
- <title>bench: unavailable</title>
15
- <rect width="120" height="20" fill="#555"/>
16
- <rect x="46" width="74" height="20" fill="#9f9f9f"/>
17
- <text x="24" y="14" fill="#fff" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11" text-anchor="middle">bench</text>
18
- <text x="83" y="14" fill="#fff" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" font-size="11" text-anchor="middle">unavailable</text>
19
- </svg>`;
20
-
21
- try {
22
- if (!existsSync(BADGE_FILE)) {
23
- await mkdir(BADGES_DIR, { recursive: true });
24
-
25
- try {
26
- execSync(`curl -fsSL "${PAGES_URL}" -o "${BADGE_FILE}"`, {
27
- stdio: "pipe",
28
- });
29
- console.log("✓ Restored prior bench badge from Pages");
30
- } catch {
31
- console.warn("::warning::No prior bench badge found on Pages; creating fallback badge.");
32
- writeFileSync(BADGE_FILE, FALLBACK_BADGE);
33
- }
34
- }
35
- } catch (error) {
36
- console.error("Error keeping prior bench badge:", error.message);
37
- process.exit(1);
38
- }