Skip to content

Commit 16aa211

Browse files
test(ci): add unit-test seam for ci-duration-report.mjs percentile/summarize
percentile() (nearest-rank with a floor clamp) and summarize() (excludes cancelled runs from both the duration set and the failure-rate denominator; treats skipped as success) carried real, non-obvious behavior but only ran inside the script's un-guarded top-level body, which also makes a live GitHub API call -- untestable in isolation. Export percentile/summarize/durationSeconds and move the env-reading, live-fetch, and report-assembly driver behind an entrypoint guard (import.meta.url === argv[1]) so importing the module for tests never fetches. Add test/unit/ci-duration-report-script.test.ts covering percentile at p50/p95 (incl. single-element and empty->null), summarize excluding cancelled from count + failure denominator (counting them as excludedCancelled), and summarize treating skipped as a success. Behavior of the script when run directly is unchanged. Closes #7456
1 parent dca5d66 commit 16aa211

2 files changed

Lines changed: 121 additions & 26 deletions

File tree

scripts/ci-duration-report.mjs

Lines changed: 36 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,9 @@
77
// including queue time), not the sum of individual job durations.
88

99
import { writeFileSync } from "node:fs";
10+
import { pathToFileURL } from "node:url";
1011

11-
const WINDOW_DAYS = Number(process.argv.find((a) => a.startsWith("--days="))?.split("=")[1] ?? 7);
12-
const outputArg = process.argv.find((a) => a.startsWith("--output="));
13-
const OUTPUT_PATH = outputArg ? outputArg.split("=")[1] : null;
14-
15-
const repo = process.env.GITHUB_REPOSITORY;
16-
if (!repo) throw new Error("GITHUB_REPOSITORY is required");
17-
const token = process.env.GITHUB_TOKEN;
18-
if (!token) throw new Error("GITHUB_TOKEN is required");
19-
20-
async function fetchAllRuns(sinceIso) {
12+
async function fetchAllRuns(repo, token, sinceIso) {
2113
const runs = [];
2214
let page = 1;
2315
for (;;) {
@@ -43,17 +35,17 @@ async function fetchAllRuns(sinceIso) {
4335
return runs;
4436
}
4537

46-
function durationSeconds(run) {
38+
export function durationSeconds(run) {
4739
return (new Date(run.updated_at).getTime() - new Date(run.created_at).getTime()) / 1000;
4840
}
4941

50-
function percentile(sortedValues, p) {
42+
export function percentile(sortedValues, p) {
5143
if (sortedValues.length === 0) return null;
5244
const index = Math.min(sortedValues.length - 1, Math.ceil((p / 100) * sortedValues.length) - 1);
5345
return sortedValues[Math.max(0, index)];
5446
}
5547

56-
function summarize(allRuns) {
48+
export function summarize(allRuns) {
5749
// "cancelled" excluded entirely, not just from the failure count: this workflow's own
5850
// cancel-in-progress concurrency setting means a cancelled run is almost always a rapid re-push
5951
// superseding its predecessor mid-run, not CI breaking -- counting it as a failure (or even as a
@@ -72,19 +64,37 @@ function summarize(allRuns) {
7264
};
7365
}
7466

75-
const since = new Date(Date.now() - WINDOW_DAYS * 24 * 60 * 60 * 1000).toISOString();
76-
const runs = await fetchAllRuns(since);
67+
// Entrypoint guard (#7456): the pure percentile/summarize/durationSeconds logic above is importable for
68+
// tests without this driving code -- which reads required env, makes a live GitHub API call, and writes
69+
// output -- ever running. Only executes when the file is invoked directly as a script.
70+
async function main() {
71+
const WINDOW_DAYS = Number(process.argv.find((a) => a.startsWith("--days="))?.split("=")[1] ?? 7);
72+
const outputArg = process.argv.find((a) => a.startsWith("--output="));
73+
const OUTPUT_PATH = outputArg ? outputArg.split("=")[1] : null;
74+
75+
const repo = process.env.GITHUB_REPOSITORY;
76+
if (!repo) throw new Error("GITHUB_REPOSITORY is required");
77+
const token = process.env.GITHUB_TOKEN;
78+
if (!token) throw new Error("GITHUB_TOKEN is required");
79+
80+
const since = new Date(Date.now() - WINDOW_DAYS * 24 * 60 * 60 * 1000).toISOString();
81+
const runs = await fetchAllRuns(repo, token, since);
7782

78-
const report = {
79-
windowDays: WINDOW_DAYS,
80-
generatedAt: new Date().toISOString(),
81-
push: summarize(runs.filter((r) => r.event === "push")),
82-
pullRequest: summarize(runs.filter((r) => r.event === "pull_request")),
83-
};
83+
const report = {
84+
windowDays: WINDOW_DAYS,
85+
generatedAt: new Date().toISOString(),
86+
push: summarize(runs.filter((r) => r.event === "push")),
87+
pullRequest: summarize(runs.filter((r) => r.event === "pull_request")),
88+
};
89+
90+
const json = JSON.stringify(report, null, 2);
91+
if (OUTPUT_PATH) {
92+
writeFileSync(OUTPUT_PATH, json);
93+
} else {
94+
process.stdout.write(`${json}\n`);
95+
}
96+
}
8497

85-
const json = JSON.stringify(report, null, 2);
86-
if (OUTPUT_PATH) {
87-
writeFileSync(OUTPUT_PATH, json);
88-
} else {
89-
process.stdout.write(`${json}\n`);
98+
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
99+
await main();
90100
}
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import {
4+
durationSeconds,
5+
percentile,
6+
summarize,
7+
} from "../../scripts/ci-duration-report.mjs";
8+
9+
// #7456: percentile/summarize/durationSeconds are the non-obvious pure logic in ci-duration-report.mjs
10+
// (a specific nearest-rank percentile with a floor clamp; a summarize() that excludes `cancelled` runs from
11+
// both the duration set and the failure-rate denominator, while treating `skipped` as a success). Importing
12+
// the module must not trigger its live-fetch driver -- the entrypoint guard now keeps that behind `main()`,
13+
// so these imports resolve to just the pure functions.
14+
15+
type Run = { conclusion: string; created_at: string; updated_at: string; event?: string };
16+
17+
function run(conclusion: string, durationMinutes: number): Run {
18+
const created = new Date("2026-01-01T00:00:00.000Z");
19+
const updated = new Date(created.getTime() + durationMinutes * 60 * 1000);
20+
return { conclusion, created_at: created.toISOString(), updated_at: updated.toISOString() };
21+
}
22+
23+
describe("percentile (#7456)", () => {
24+
const sorted = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100];
25+
26+
it("returns the nearest-rank p50 and p95 of a known sorted array", () => {
27+
expect(percentile(sorted, 50)).toBe(50);
28+
expect(percentile(sorted, 95)).toBe(100);
29+
});
30+
31+
it("returns the sole element for every percentile of a single-element array", () => {
32+
expect(percentile([42], 50)).toBe(42);
33+
expect(percentile([42], 95)).toBe(42);
34+
});
35+
36+
it("returns null for an empty array", () => {
37+
expect(percentile([], 50)).toBeNull();
38+
expect(percentile([], 95)).toBeNull();
39+
});
40+
});
41+
42+
describe("durationSeconds (#7456)", () => {
43+
it("measures wall-clock span as (updated_at - created_at) in seconds", () => {
44+
expect(durationSeconds(run("success", 10))).toBe(600);
45+
});
46+
});
47+
48+
describe("summarize (#7456)", () => {
49+
it("excludes cancelled runs from count and the failure-rate denominator, but reports them as excludedCancelled", () => {
50+
const summary = summarize([
51+
run("success", 10),
52+
run("failure", 20),
53+
run("skipped", 5),
54+
run("cancelled", 99),
55+
run("cancelled", 1),
56+
]);
57+
58+
// cancelled runs are dropped from the counted set entirely...
59+
expect(summary.count).toBe(3);
60+
expect(summary.excludedCancelled).toBe(2);
61+
// ...and from the failure-rate denominator: 1 failure out of the 3 non-cancelled runs.
62+
expect(summary.failures).toBe(1);
63+
expect(summary.failureRate).toBe(1 / 3);
64+
// durations only reflect the 3 non-cancelled runs (5/10/20 min -> 300/600/1200s).
65+
expect(summary.p50Seconds).toBe(600);
66+
expect(summary.p95Seconds).toBe(1200);
67+
});
68+
69+
it("treats a skipped run as a success, not a failure", () => {
70+
const summary = summarize([run("success", 10), run("skipped", 5)]);
71+
expect(summary.count).toBe(2);
72+
expect(summary.failures).toBe(0);
73+
expect(summary.failureRate).toBe(0);
74+
});
75+
76+
it("returns a null failureRate and null percentiles for an empty run set", () => {
77+
const summary = summarize([]);
78+
expect(summary.count).toBe(0);
79+
expect(summary.excludedCancelled).toBe(0);
80+
expect(summary.failures).toBe(0);
81+
expect(summary.failureRate).toBeNull();
82+
expect(summary.p50Seconds).toBeNull();
83+
expect(summary.p95Seconds).toBeNull();
84+
});
85+
});

0 commit comments

Comments
 (0)