@decohere/ci-tools 0.0.1-dev.20 → 0.0.1-dev.20261105.54
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.
- package/LICENSE +15 -0
- package/package.json +17 -3
- package/src/audit/enforce.mjs +7 -5
- package/src/audit/format.mjs +133 -0
- package/src/audit/summarize.mjs +10 -32
- package/src/audit/upsert-issue.mjs +25 -74
- package/src/bench/bench.mjs +135 -368
- package/src/bench/file-regressions.mjs +76 -0
- package/src/bench/history.mjs +248 -0
- package/src/bench/nightly.mjs +118 -0
- package/src/bench/regression-issue.mjs +44 -0
- package/src/bench/trace.mjs +18 -14
- package/src/github/npm-bootstrap.mjs +179 -0
- package/src/github/oidc-bootstrap-nudge.mjs +214 -0
- package/src/github/publish-verify.mjs +206 -0
- package/src/github/smoke-create-launcher.mjs +85 -0
- package/src/lib/github-issue.mjs +75 -0
- package/src/lib/lcov.mjs +1 -1
- package/src/lib/workspace-packages.mjs +30 -0
- package/src/pages/artifact.mjs +38 -183
- package/src/pages/bench-page.mjs +324 -0
- package/src/pages/restore-badges.mjs +35 -0
- package/src/pages/restore-bench-history.mjs +15 -28
- package/src/release/npm-links.mjs +6 -13
- package/src/release/sync-dependencies.mjs +1 -2
- package/tests/audit/format.test.mjs +115 -0
- package/tests/bench/history.test.mjs +279 -0
- package/tests/bench/regression-issue.test.mjs +42 -0
- package/tests/github/ci-workflow.test.mjs +19 -0
- package/tests/github/npm-bootstrap.test.mjs +125 -0
- package/tests/github/oidc-bootstrap-nudge.test.mjs +89 -0
- package/tests/github/package-metadata.test.mjs +69 -0
- package/tests/github/publish-verify.test.mjs +115 -0
- package/tests/github/smoke-create-launcher.test.mjs +132 -0
- package/tests/lib/github-issue.test.mjs +85 -0
- package/tests/lib/lcov.test.mjs +8 -8
- package/tests/pages/bench-page.test.mjs +123 -0
- package/src/coverage/enforce-thresholds.mjs +0 -144
- package/src/pages/clear-bench-history.mjs +0 -27
- package/src/pages/keep-prior-bench-badge.mjs +0 -38
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
buildDescription,
|
|
4
|
+
extractGitHubReviewerLogins,
|
|
5
|
+
firstPublishCommands,
|
|
6
|
+
issueTitle,
|
|
7
|
+
needsPrereleaseTag,
|
|
8
|
+
parseUnpublished,
|
|
9
|
+
runNudge,
|
|
10
|
+
} from "../../src/github/oidc-bootstrap-nudge.mjs";
|
|
11
|
+
|
|
12
|
+
describe("oidc-bootstrap-nudge", () => {
|
|
13
|
+
it("parses unpublished names", () => {
|
|
14
|
+
expect(parseUnpublished(" @decohere/scaffold @decohere/cli ")).toEqual([
|
|
15
|
+
"@decohere/scaffold",
|
|
16
|
+
"@decohere/cli",
|
|
17
|
+
]);
|
|
18
|
+
expect(issueTitle()).toBe("deploy(publish): OIDC bootstrap unpublished packages");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("extracts GitHub user reviewers and skips teams", () => {
|
|
22
|
+
expect(
|
|
23
|
+
extractGitHubReviewerLogins({
|
|
24
|
+
protection_rules: [
|
|
25
|
+
{ type: "branch_policy", reviewers: [] },
|
|
26
|
+
{
|
|
27
|
+
type: "required_reviewers",
|
|
28
|
+
reviewers: [
|
|
29
|
+
{ type: "User", reviewer: { login: "das974" } },
|
|
30
|
+
{ type: "Team", reviewer: { slug: "publishers", login: "should-skip" } },
|
|
31
|
+
],
|
|
32
|
+
},
|
|
33
|
+
],
|
|
34
|
+
}),
|
|
35
|
+
).toEqual(["das974"]);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("puts first-publish commands and Trusted Publisher fields in the task body", () => {
|
|
39
|
+
const pkg = { name: "@decohere/scaffold", dir: "scaffold", version: "0.0.1-dev.18" };
|
|
40
|
+
const body = buildDescription({
|
|
41
|
+
unpublished: ["@decohere/scaffold"],
|
|
42
|
+
packages: [pkg],
|
|
43
|
+
repo: "typesmiths/decohere",
|
|
44
|
+
runUrl: "https://github.com/typesmiths/decohere/actions/runs/9",
|
|
45
|
+
channel: "dev",
|
|
46
|
+
});
|
|
47
|
+
expect(needsPrereleaseTag("0.0.1-dev.18")).toBe(true);
|
|
48
|
+
expect(needsPrereleaseTag("1.2.3")).toBe(false);
|
|
49
|
+
expect(firstPublishCommands([pkg], "dev")).toEqual([
|
|
50
|
+
"npm publish ./projects/scaffold --access public --tag dev",
|
|
51
|
+
]);
|
|
52
|
+
expect(firstPublishCommands([{ ...pkg, version: "1.0.0" }], "dev")).toEqual([
|
|
53
|
+
"npm publish ./projects/scaffold --access public",
|
|
54
|
+
]);
|
|
55
|
+
expect(body).toContain("npm publish ./projects/scaffold --access public --tag dev");
|
|
56
|
+
expect(body).toContain("pnpm npm:bootstrap --otp <code> @decohere/scaffold");
|
|
57
|
+
expect(body).not.toMatch(/# @decohere\/scaffold/);
|
|
58
|
+
expect(body).toContain("First-publish each name **public**");
|
|
59
|
+
expect(body).toContain("Workflow filename: `publish.yml`");
|
|
60
|
+
expect(body).toContain("Environment: `npm-publish`");
|
|
61
|
+
expect(body).toContain("gh run rerun --failed");
|
|
62
|
+
expect(body).toContain("https://github.com/typesmiths/decohere/actions/runs/9");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("assigns the bootstrap environment's reviewers to the issue", async () => {
|
|
66
|
+
/** @type {object[]} */
|
|
67
|
+
const upserts = [];
|
|
68
|
+
const code = await runNudge(
|
|
69
|
+
{ UNPUBLISHED: "@decohere/scaffold", GITHUB_REPOSITORY: "typesmiths/decohere" },
|
|
70
|
+
{
|
|
71
|
+
listPackages: () => [{ name: "@decohere/scaffold", dir: "scaffold" }],
|
|
72
|
+
githubReviewers: async () => ["das974"],
|
|
73
|
+
upsert: (options) => {
|
|
74
|
+
upserts.push(options);
|
|
75
|
+
return {
|
|
76
|
+
number: 5,
|
|
77
|
+
url: "https://github.com/typesmiths/decohere/issues/5",
|
|
78
|
+
created: true,
|
|
79
|
+
};
|
|
80
|
+
},
|
|
81
|
+
runUrl: "https://github.com/typesmiths/decohere/actions/runs/1",
|
|
82
|
+
},
|
|
83
|
+
);
|
|
84
|
+
expect(code).toBe(0);
|
|
85
|
+
expect(upserts[0].title).toBe(issueTitle());
|
|
86
|
+
expect(upserts[0].assignees).toEqual(["das974"]);
|
|
87
|
+
expect(upserts[0].body).toContain("`@decohere/scaffold`");
|
|
88
|
+
});
|
|
89
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { join, relative } from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { vol } from "memfs";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { listPublishablePackages } from "../../src/github/publish-verify.mjs";
|
|
6
|
+
|
|
7
|
+
const fs = await vi.importActual("node:fs");
|
|
8
|
+
const REPO_ROOT = fileURLToPath(new URL("../../../../", import.meta.url));
|
|
9
|
+
const REPO = "https://github.com/typesmiths/decohere";
|
|
10
|
+
|
|
11
|
+
/** The repo's real package.json files, loaded into the in-memory fs the tests run on. */
|
|
12
|
+
function loadRealManifests() {
|
|
13
|
+
/** @type {Record<string, string>} */
|
|
14
|
+
const files = {};
|
|
15
|
+
const walk = (dir) => {
|
|
16
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
17
|
+
const path = join(dir, entry.name);
|
|
18
|
+
if (entry.isDirectory() && !["node_modules", "dist"].includes(entry.name)) {
|
|
19
|
+
walk(path);
|
|
20
|
+
} else if (entry.name === "package.json") {
|
|
21
|
+
files[join("/repo", relative(REPO_ROOT, path))] = fs.readFileSync(path, "utf8");
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
walk(join(REPO_ROOT, "projects"));
|
|
26
|
+
return files;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
vol.reset();
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// npm shows these on each package's page, and Trusted Publishing's provenance is
|
|
34
|
+
// checked against `repository.url`, so a package without it fails to publish.
|
|
35
|
+
describe("published package metadata", () => {
|
|
36
|
+
vol.fromJSON(loadRealManifests());
|
|
37
|
+
const packages = listPublishablePackages("/repo").map((pkg) => ({
|
|
38
|
+
...pkg,
|
|
39
|
+
json: JSON.parse(String(vol.readFileSync(`/repo/projects/${pkg.dir}/package.json`))),
|
|
40
|
+
}));
|
|
41
|
+
vol.reset();
|
|
42
|
+
|
|
43
|
+
it("finds the publishable packages", () => {
|
|
44
|
+
expect(packages.length).toBeGreaterThan(0);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it.each(packages.map((pkg) => [pkg.name, pkg]))(
|
|
48
|
+
"%s links back to the repo",
|
|
49
|
+
(_name, pkg) => {
|
|
50
|
+
expect(pkg.json.repository).toEqual({
|
|
51
|
+
type: "git",
|
|
52
|
+
url: `git+${REPO}.git`,
|
|
53
|
+
directory: `projects/${pkg.dir}`,
|
|
54
|
+
});
|
|
55
|
+
expect(pkg.json.homepage).toBe(`${REPO}/tree/main/projects/${pkg.dir}#readme`);
|
|
56
|
+
expect(pkg.json.bugs).toEqual({ url: `${REPO}/issues` });
|
|
57
|
+
},
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
it.each(packages.map((pkg) => [pkg.name, pkg]))(
|
|
61
|
+
"%s has a description, keywords, license and author",
|
|
62
|
+
(_name, pkg) => {
|
|
63
|
+
expect(pkg.json.description).toBeTruthy();
|
|
64
|
+
expect(pkg.json.keywords?.length).toBeGreaterThan(0);
|
|
65
|
+
expect(pkg.json.license).toBe("ISC");
|
|
66
|
+
expect(pkg.json.author).toBeTruthy();
|
|
67
|
+
},
|
|
68
|
+
);
|
|
69
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { vol } from "memfs";
|
|
2
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
3
|
+
import {
|
|
4
|
+
collectPublishVerify,
|
|
5
|
+
formatGithubOutput,
|
|
6
|
+
listPublishablePackages,
|
|
7
|
+
parseGhcrRepository,
|
|
8
|
+
} from "../../src/github/publish-verify.mjs";
|
|
9
|
+
|
|
10
|
+
function seedWorkspace() {
|
|
11
|
+
const root = "/workspace";
|
|
12
|
+
vol.fromJSON({
|
|
13
|
+
[`${root}/package.json`]: JSON.stringify({ version: "0.0.1-dev.79" }),
|
|
14
|
+
[`${root}/projects/cli/package.json`]: JSON.stringify({
|
|
15
|
+
name: "@decohere/cli",
|
|
16
|
+
version: "0.0.1-dev.79",
|
|
17
|
+
}),
|
|
18
|
+
[`${root}/projects/scaffold/package.json`]: JSON.stringify({
|
|
19
|
+
name: "@decohere/scaffold",
|
|
20
|
+
version: "0.0.1-dev.79",
|
|
21
|
+
private: true,
|
|
22
|
+
}),
|
|
23
|
+
[`${root}/projects/create/package.json`]: JSON.stringify({
|
|
24
|
+
name: "@decohere/create",
|
|
25
|
+
version: "0.0.1-dev.79",
|
|
26
|
+
decohere: { dockerImageRepository: "ghcr.io/typesmiths/decohere/create" },
|
|
27
|
+
}),
|
|
28
|
+
[`${root}/projects/devcontainer/package.json`]: JSON.stringify({
|
|
29
|
+
name: "@decohere/devcontainer",
|
|
30
|
+
private: true,
|
|
31
|
+
decohere: { dockerImageRepository: "ghcr.io/typesmiths/decohere/devcontainer" },
|
|
32
|
+
}),
|
|
33
|
+
});
|
|
34
|
+
return root;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
vol.reset();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("publish-verify", () => {
|
|
42
|
+
it("lists non-private workspace packages", () => {
|
|
43
|
+
const root = seedWorkspace();
|
|
44
|
+
expect(listPublishablePackages(root).map((pkg) => pkg.name)).toEqual([
|
|
45
|
+
"@decohere/cli",
|
|
46
|
+
"@decohere/create",
|
|
47
|
+
]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("lists grouped packages (projects/<group>/<name>) and skips nested and build folders", () => {
|
|
51
|
+
const root = seedWorkspace();
|
|
52
|
+
vol.fromJSON({
|
|
53
|
+
[`${root}/projects/data/data/package.json`]: JSON.stringify({ name: "@decohere/data" }),
|
|
54
|
+
[`${root}/projects/data/data-typeorm/package.json`]: JSON.stringify({
|
|
55
|
+
name: "@decohere/data-typeorm",
|
|
56
|
+
}),
|
|
57
|
+
[`${root}/projects/cli/examples/demo/package.json`]: JSON.stringify({ name: "nested" }),
|
|
58
|
+
[`${root}/projects/__fixtures__/app/package.json`]: JSON.stringify({ name: "fixture" }),
|
|
59
|
+
[`${root}/projects/node_modules/dep/package.json`]: JSON.stringify({ name: "dep" }),
|
|
60
|
+
[`${root}/projects/dist/out/package.json`]: JSON.stringify({ name: "built" }),
|
|
61
|
+
});
|
|
62
|
+
expect(listPublishablePackages(root).map((pkg) => pkg.name)).toEqual([
|
|
63
|
+
"@decohere/cli",
|
|
64
|
+
"@decohere/create",
|
|
65
|
+
"@decohere/data",
|
|
66
|
+
"@decohere/data-typeorm",
|
|
67
|
+
]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("parses GHCR repository coordinates", () => {
|
|
71
|
+
expect(parseGhcrRepository("ghcr.io/typesmiths/decohere/create")).toEqual({
|
|
72
|
+
org: "typesmiths",
|
|
73
|
+
packageName: "decohere/create",
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("marks names missing from npm as unpublished and skip_npm when every version exists", async () => {
|
|
78
|
+
const root = seedWorkspace();
|
|
79
|
+
const views = [];
|
|
80
|
+
const spawnSync = (_cmd, args) => {
|
|
81
|
+
views.push(args[1]);
|
|
82
|
+
if (args[1] === "@decohere/cli") {
|
|
83
|
+
return { status: 1 };
|
|
84
|
+
}
|
|
85
|
+
return { status: 0 };
|
|
86
|
+
};
|
|
87
|
+
const result = await collectPublishVerify({
|
|
88
|
+
root,
|
|
89
|
+
spawnSync,
|
|
90
|
+
githubToken: "",
|
|
91
|
+
});
|
|
92
|
+
expect(result.unpublished).toEqual(["@decohere/cli"]);
|
|
93
|
+
expect(result.skipNpm).toBe(false);
|
|
94
|
+
expect(views).toContain("@decohere/cli");
|
|
95
|
+
expect(views).toContain("@decohere/create");
|
|
96
|
+
expect(views).toContain("@decohere/create@0.0.1-dev.79");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("sets skip_docker when both GHCR tags exist", async () => {
|
|
100
|
+
const root = seedWorkspace();
|
|
101
|
+
const fetch = async () => ({
|
|
102
|
+
ok: true,
|
|
103
|
+
status: 200,
|
|
104
|
+
json: async () => [{ metadata: { container: { tags: ["0.0.1-dev.79"] } } }],
|
|
105
|
+
});
|
|
106
|
+
const result = await collectPublishVerify({
|
|
107
|
+
root,
|
|
108
|
+
spawnSync: () => ({ status: 0 }),
|
|
109
|
+
githubToken: "ghs_test",
|
|
110
|
+
fetch,
|
|
111
|
+
});
|
|
112
|
+
expect(result.skipDocker).toBe(true);
|
|
113
|
+
expect(formatGithubOutput(result)).toContain("skip_docker=true");
|
|
114
|
+
});
|
|
115
|
+
});
|
|
@@ -0,0 +1,132 @@
|
|
|
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
|
+
tmpdir: () => "/tmp",
|
|
19
|
+
mkdtempSync: (prefix) => `${prefix}abc`,
|
|
20
|
+
writeFileSync: (path, body) => {
|
|
21
|
+
written.push({ path, body });
|
|
22
|
+
},
|
|
23
|
+
spawnSync: (cmd, args, opts) => {
|
|
24
|
+
calls.push({ cmd, args, opts });
|
|
25
|
+
return { status: 0 };
|
|
26
|
+
},
|
|
27
|
+
stdio: "pipe",
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
expect(result.workDir).toBe("/tmp/decohere-create-smoke-abc");
|
|
31
|
+
expect(result.workDir).not.toBe(process.cwd());
|
|
32
|
+
const npx = calls.at(-1);
|
|
33
|
+
expect(npx.cmd).toBe("npx");
|
|
34
|
+
expect(npx.args).toEqual(["--yes", "@decohere/create@0.0.1-dev.80", "--help"]);
|
|
35
|
+
expect(npx.opts.cwd).toBe("/tmp/decohere-create-smoke-abc");
|
|
36
|
+
expect(npx.opts.cwd).not.toBe(process.cwd());
|
|
37
|
+
expect(written).toEqual([
|
|
38
|
+
{
|
|
39
|
+
path: "/tmp/decohere-create-smoke-abc/.npmrc",
|
|
40
|
+
body: "@decohere:registry=https://registry.npmjs.org/\n",
|
|
41
|
+
},
|
|
42
|
+
]);
|
|
43
|
+
// The packages are public: the smoke installs them the way a user does, with no token.
|
|
44
|
+
expect(npx.opts.env.NODE_AUTH_TOKEN).toBeUndefined();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// npm's public cache can lag minutes behind a publish: the first token-free Publish ran npx
|
|
48
|
+
// 90 seconds after publishing and got ETARGET for a version the registry already had.
|
|
49
|
+
it("waits until npm serves the new version before running npx", () => {
|
|
50
|
+
/** @type {{ cmd: string, args: string[] }[]} */
|
|
51
|
+
const calls = [];
|
|
52
|
+
const sleeps = [];
|
|
53
|
+
let views = 0;
|
|
54
|
+
|
|
55
|
+
const result = runCreateLauncherSmoke({
|
|
56
|
+
createVersion: "0.0.1-dev.127",
|
|
57
|
+
tmpdir: () => "/tmp",
|
|
58
|
+
mkdtempSync: (prefix) => `${prefix}abc`,
|
|
59
|
+
writeFileSync: () => {},
|
|
60
|
+
spawnSync: (cmd, args) => {
|
|
61
|
+
calls.push({ cmd, args });
|
|
62
|
+
if (cmd === "npm") return { status: ++views < 3 ? 1 : 0 };
|
|
63
|
+
return { status: 0 };
|
|
64
|
+
},
|
|
65
|
+
sleep: (ms) => sleeps.push(ms),
|
|
66
|
+
stdio: "pipe",
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
expect(result.status).toBe(0);
|
|
70
|
+
expect(calls.map((call) => call.cmd)).toEqual(["npm", "npm", "npm", "npx"]);
|
|
71
|
+
expect(calls[0].args).toEqual(["view", "@decohere/create@0.0.1-dev.127", "version"]);
|
|
72
|
+
expect(sleeps).toHaveLength(2);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("fails without running npx when npm never serves the version", () => {
|
|
76
|
+
/** @type {string[]} */
|
|
77
|
+
const commands = [];
|
|
78
|
+
|
|
79
|
+
const result = runCreateLauncherSmoke({
|
|
80
|
+
createVersion: "0.0.1-dev.127",
|
|
81
|
+
tmpdir: () => "/tmp",
|
|
82
|
+
mkdtempSync: (prefix) => `${prefix}abc`,
|
|
83
|
+
writeFileSync: () => {},
|
|
84
|
+
spawnSync: (cmd) => {
|
|
85
|
+
commands.push(cmd);
|
|
86
|
+
return { status: 1 };
|
|
87
|
+
},
|
|
88
|
+
sleep: () => {},
|
|
89
|
+
attempts: 3,
|
|
90
|
+
stdio: "pipe",
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
expect(result.status).toBe(1);
|
|
94
|
+
expect(commands).toEqual(["npm", "npm", "npm"]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("fails closed without a version", () => {
|
|
98
|
+
expect(() => runCreateLauncherSmoke({ createVersion: "" })).toThrow(
|
|
99
|
+
"CREATE_VERSION is required",
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("publish.yml create smoke + tag", () => {
|
|
105
|
+
const yml = fs.readFileSync(PUBLISH_YML, "utf8");
|
|
106
|
+
|
|
107
|
+
it("wires create smoke through the isolated launcher", () => {
|
|
108
|
+
expect(yml).toContain("node projects/ci-tools/src/github/smoke-create-launcher.mjs");
|
|
109
|
+
expect(yml).toContain("CREATE_VERSION:");
|
|
110
|
+
expect(yml).not.toContain("npx --yes");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("reads npm without a token: the packages are public and publishing is OIDC", () => {
|
|
114
|
+
const workflows = fileURLToPath(new URL("../../../../.github/", import.meta.url));
|
|
115
|
+
const files = fs
|
|
116
|
+
.readdirSync(workflows, { recursive: true })
|
|
117
|
+
.filter((file) => /\.ya?ml$/.test(file));
|
|
118
|
+
expect(files.length).toBeGreaterThan(0);
|
|
119
|
+
for (const file of files) {
|
|
120
|
+
expect(fs.readFileSync(`${workflows}${file}`, "utf8"), file).not.toMatch(
|
|
121
|
+
/NODE_AUTH_TOKEN|_authToken/,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("grants the tag job actions:read so publish-prepare can download CI artifacts", () => {
|
|
127
|
+
const tag = yml.slice(yml.indexOf("\n tag:"));
|
|
128
|
+
const perms = tag.slice(tag.indexOf("permissions:"), tag.indexOf("steps:"));
|
|
129
|
+
expect(perms).toMatch(/contents:\s*write/);
|
|
130
|
+
expect(perms).toMatch(/actions:\s*read/);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { pickIssue, upsertIssue } from "../../src/lib/github-issue.mjs";
|
|
3
|
+
|
|
4
|
+
const TITLE = "security(ci): high or critical npm audit findings";
|
|
5
|
+
|
|
6
|
+
/** A fake `gh` that answers `issue list` with `issues` and records every call. */
|
|
7
|
+
function fakeGh(issues) {
|
|
8
|
+
/** @type {{ args: string[], input?: string }[]} */
|
|
9
|
+
const calls = [];
|
|
10
|
+
const run = (args, input) => {
|
|
11
|
+
calls.push({ args, input });
|
|
12
|
+
if (args[1] === "list") return JSON.stringify(issues);
|
|
13
|
+
if (args[1] === "create") return "https://github.com/typesmiths/decohere/issues/42\n";
|
|
14
|
+
return "";
|
|
15
|
+
};
|
|
16
|
+
return { run, calls };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("github-issue", () => {
|
|
20
|
+
it("matches the exact title only and prefers an open issue", () => {
|
|
21
|
+
const issues = [
|
|
22
|
+
{ number: 1, title: TITLE, state: "CLOSED", url: "u1" },
|
|
23
|
+
{ number: 2, title: `${TITLE} (old)`, state: "OPEN", url: "u2" },
|
|
24
|
+
{ number: 3, title: TITLE, state: "OPEN", url: "u3" },
|
|
25
|
+
];
|
|
26
|
+
expect(pickIssue(issues, TITLE)?.number).toBe(3);
|
|
27
|
+
expect(pickIssue(issues.slice(0, 2), TITLE)?.number).toBe(1);
|
|
28
|
+
expect(pickIssue([], TITLE)).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// GitHub's search can't match `security(ci): …` unquoted, so the lookup found nothing
|
|
32
|
+
// and a new issue was opened on every run.
|
|
33
|
+
it("searches for the title as a quoted phrase", () => {
|
|
34
|
+
const { run, calls } = fakeGh([]);
|
|
35
|
+
upsertIssue({ title: "security(ci): npm audit vulnerabilities detected", body: "b" }, run);
|
|
36
|
+
const args = calls[0].args;
|
|
37
|
+
expect(args[args.indexOf("--search") + 1]).toBe(
|
|
38
|
+
'"security(ci): npm audit vulnerabilities detected" in:title',
|
|
39
|
+
);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("opens an issue with assignees when none has the title", () => {
|
|
43
|
+
const { run, calls } = fakeGh([]);
|
|
44
|
+
const result = upsertIssue({ title: TITLE, body: "body", assignees: ["das974"] }, run);
|
|
45
|
+
expect(result).toEqual({
|
|
46
|
+
number: 42,
|
|
47
|
+
url: "https://github.com/typesmiths/decohere/issues/42",
|
|
48
|
+
created: true,
|
|
49
|
+
});
|
|
50
|
+
expect(calls[1]).toEqual({
|
|
51
|
+
args: [
|
|
52
|
+
"issue",
|
|
53
|
+
"create",
|
|
54
|
+
"--title",
|
|
55
|
+
TITLE,
|
|
56
|
+
"--body-file",
|
|
57
|
+
"-",
|
|
58
|
+
"--add-assignee",
|
|
59
|
+
"das974",
|
|
60
|
+
],
|
|
61
|
+
input: "body",
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("updates an open issue's body in place", () => {
|
|
66
|
+
const { run, calls } = fakeGh([{ number: 7, title: TITLE, state: "OPEN", url: "u7" }]);
|
|
67
|
+
const result = upsertIssue({ title: TITLE, body: "new body" }, run);
|
|
68
|
+
expect(result).toEqual({ number: 7, url: "u7", created: false });
|
|
69
|
+
expect(calls.map((call) => call.args[1])).toEqual(["list", "edit"]);
|
|
70
|
+
expect(calls[1]).toEqual({
|
|
71
|
+
args: ["issue", "edit", "7", "--body-file", "-"],
|
|
72
|
+
input: "new body",
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("reopens a closed issue before updating it", () => {
|
|
77
|
+
const { run, calls } = fakeGh([{ number: 7, title: TITLE, state: "CLOSED", url: "u7" }]);
|
|
78
|
+
upsertIssue({ title: TITLE, body: "b", repo: "typesmiths/decohere" }, run);
|
|
79
|
+
expect(calls.map((call) => call.args.slice(0, 3))).toEqual([
|
|
80
|
+
["issue", "list", "--repo"],
|
|
81
|
+
["issue", "reopen", "7"],
|
|
82
|
+
["issue", "edit", "7"],
|
|
83
|
+
]);
|
|
84
|
+
});
|
|
85
|
+
});
|
package/tests/lib/lcov.test.mjs
CHANGED
|
@@ -13,13 +13,13 @@ SF:projects/forge/src/build/index.ts
|
|
|
13
13
|
LF:20
|
|
14
14
|
LH:15
|
|
15
15
|
end_of_record
|
|
16
|
-
SF:projects/
|
|
16
|
+
SF:projects/core/src/index.ts
|
|
17
17
|
LF:5
|
|
18
18
|
LH:5
|
|
19
19
|
end_of_record
|
|
20
20
|
`;
|
|
21
21
|
|
|
22
|
-
const sampleLcovWithLines = `SF:projects/
|
|
22
|
+
const sampleLcovWithLines = `SF:projects/http/src/index.ts
|
|
23
23
|
DA:1,1
|
|
24
24
|
DA:2,1
|
|
25
25
|
DA:3,0
|
|
@@ -46,9 +46,9 @@ describe("parseLcovByProject", () => {
|
|
|
46
46
|
expect(result.get("forge")).toBeDefined();
|
|
47
47
|
expect(result.get("forge").totalLines).toBe(30);
|
|
48
48
|
expect(result.get("forge").coveredLines).toBe(24);
|
|
49
|
-
expect(result.get("
|
|
50
|
-
expect(result.get("
|
|
51
|
-
expect(result.get("
|
|
49
|
+
expect(result.get("core")).toBeDefined();
|
|
50
|
+
expect(result.get("core").totalLines).toBe(5);
|
|
51
|
+
expect(result.get("core").coveredLines).toBe(5);
|
|
52
52
|
});
|
|
53
53
|
|
|
54
54
|
it("ignores records with LF:0", () => {
|
|
@@ -61,7 +61,7 @@ describe("parseLcovByProject", () => {
|
|
|
61
61
|
describe("parseLcovFiles", () => {
|
|
62
62
|
it("returns per-file coverage with uncovered line numbers", () => {
|
|
63
63
|
const result = parseLcovFiles(sampleLcovWithLines);
|
|
64
|
-
const file = result.find((f) => f.sourcePath === "projects/
|
|
64
|
+
const file = result.find((f) => f.sourcePath === "projects/http/src/index.ts");
|
|
65
65
|
expect(file).toBeDefined();
|
|
66
66
|
expect(file.totalLines).toBe(5);
|
|
67
67
|
expect(file.coveredLines).toBe(3);
|
|
@@ -70,14 +70,14 @@ describe("parseLcovFiles", () => {
|
|
|
70
70
|
});
|
|
71
71
|
|
|
72
72
|
it("includes files with 100% coverage", () => {
|
|
73
|
-
const lcov = `SF:projects/
|
|
73
|
+
const lcov = `SF:projects/http/src/full.ts\nDA:1,1\nDA:2,1\nLF:2\nLH:2\nend_of_record\n`;
|
|
74
74
|
const result = parseLcovFiles(lcov);
|
|
75
75
|
expect(result).toHaveLength(1);
|
|
76
76
|
expect(result[0].uncoveredLineNumbers).toEqual([]);
|
|
77
77
|
});
|
|
78
78
|
|
|
79
79
|
it("excludes files with LF:0", () => {
|
|
80
|
-
const lcov = `SF:projects/
|
|
80
|
+
const lcov = `SF:projects/http/src/empty.ts\nLF:0\nLH:0\nend_of_record\n`;
|
|
81
81
|
const result = parseLcovFiles(lcov);
|
|
82
82
|
expect(result).toHaveLength(0);
|
|
83
83
|
});
|
|
@@ -0,0 +1,123 @@
|
|
|
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("leaves out machine types with no run in the 30 days before the newest run", () => {
|
|
85
|
+
const html = buildBenchmarkHtml({
|
|
86
|
+
version: 2,
|
|
87
|
+
contexts: {
|
|
88
|
+
...history.contexts,
|
|
89
|
+
gone: {
|
|
90
|
+
context: context("gh-actions-node24-retired", 2),
|
|
91
|
+
runs: [
|
|
92
|
+
run("2026-08-01T03:17:00Z", "dddddddddddd", [result("flux.serve.start", 70)]),
|
|
93
|
+
],
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
expect(html).not.toContain("gh-actions-node24-retired");
|
|
98
|
+
expect(html).toContain("(local)");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("escapes labels from the history", () => {
|
|
102
|
+
const html = buildBenchmarkHtml(history);
|
|
103
|
+
expect(html).toContain("spawn <to> ready");
|
|
104
|
+
expect(html).not.toContain("spawn <to> ready");
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("trendSvg", () => {
|
|
109
|
+
it("places a single point without dividing by a zero time range", () => {
|
|
110
|
+
const svg = trendSvg([
|
|
111
|
+
{
|
|
112
|
+
color: "red",
|
|
113
|
+
points: [{ time: 1, date: "2026-10-20", sha: "abc", median: 5, p90: 6 }],
|
|
114
|
+
},
|
|
115
|
+
]);
|
|
116
|
+
expect(svg).toContain("<circle");
|
|
117
|
+
expect(svg).not.toContain("NaN");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("draws nothing when no machine has a point", () => {
|
|
121
|
+
expect(trendSvg([])).toBe("");
|
|
122
|
+
});
|
|
123
|
+
});
|