coverage-check 0.2.1 → 0.2.2
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/README.md +9 -5
- package/bin/coverage-check.mjs +4 -0
- package/dist/src/cli.d.mts +1 -0
- package/dist/src/cli.mjs +14 -0
- package/dist/src/commands/check-args.d.mts +20 -0
- package/dist/src/commands/check-args.mjs +89 -0
- package/dist/src/commands/check.d.mts +4 -0
- package/dist/src/commands/check.mjs +128 -0
- package/dist/src/commands/store-put.d.mts +11 -0
- package/dist/src/commands/store-put.mjs +104 -0
- package/{src/coverage-check.mts → dist/src/coverage-check.d.mts} +1 -9
- package/dist/src/coverage-check.mjs +4 -0
- package/dist/src/diff-parser.d.mts +17 -0
- package/dist/src/diff-parser.mjs +127 -0
- package/dist/src/github-comment.d.mts +9 -0
- package/dist/src/github-comment.mjs +66 -0
- package/dist/src/lcov-merge.d.mts +5 -0
- package/dist/src/lcov-merge.mjs +29 -0
- package/dist/src/lcov-parser.d.mts +8 -0
- package/dist/src/lcov-parser.mjs +44 -0
- package/dist/src/load-artifacts.d.mts +9 -0
- package/dist/src/load-artifacts.mjs +41 -0
- package/dist/src/patch-coverage.d.mts +5 -0
- package/dist/src/patch-coverage.mjs +65 -0
- package/dist/src/report.d.mts +4 -0
- package/dist/src/report.mjs +65 -0
- package/dist/src/rules.d.mts +4 -0
- package/dist/src/rules.mjs +30 -0
- package/dist/src/s3-suite-store.d.mts +28 -0
- package/dist/src/s3-suite-store.mjs +147 -0
- package/dist/src/s3-utils.d.mts +2 -0
- package/dist/src/s3-utils.mjs +14 -0
- package/dist/src/step-summary.d.mts +9 -0
- package/dist/src/step-summary.mjs +70 -0
- package/dist/src/store-factory.d.mts +11 -0
- package/dist/src/store-factory.mjs +23 -0
- package/dist/src/suite-store.d.mts +51 -0
- package/dist/src/suite-store.mjs +154 -0
- package/dist/src/types.d.mts +36 -0
- package/dist/src/types.mjs +1 -0
- package/package.json +19 -5
- package/bin/coverage-check.mts +0 -6
- package/src/cli.mts +0 -15
- package/src/cli.test.mts +0 -45
- package/src/commands/check-args.mts +0 -110
- package/src/commands/check.mts +0 -147
- package/src/commands/check.test.mts +0 -870
- package/src/commands/store-put.mts +0 -115
- package/src/commands/store-put.test.mts +0 -248
- package/src/diff-parser.mts +0 -127
- package/src/diff-parser.test.mts +0 -178
- package/src/github-comment.mts +0 -79
- package/src/github-comment.test.mts +0 -63
- package/src/lcov-merge.mts +0 -34
- package/src/lcov-merge.test.mts +0 -57
- package/src/lcov-parser.mts +0 -46
- package/src/lcov-parser.test.mts +0 -86
- package/src/load-artifacts.mts +0 -42
- package/src/load-artifacts.test.mts +0 -115
- package/src/patch-coverage.mts +0 -82
- package/src/patch-coverage.test.mts +0 -91
- package/src/report.mts +0 -78
- package/src/report.test.mts +0 -142
- package/src/rules.mts +0 -34
- package/src/rules.test.mts +0 -98
- package/src/s3-suite-store.mts +0 -138
- package/src/s3-suite-store.test.mts +0 -308
- package/src/step-summary.mts +0 -89
- package/src/step-summary.test.mts +0 -189
- package/src/store-factory.mts +0 -23
- package/src/store-factory.test.mts +0 -67
- package/src/suite-store.mts +0 -112
- package/src/suite-store.test.mts +0 -209
- package/src/types.mts +0 -43
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { parseLcov } from "./lcov-parser.mts";
|
|
3
|
-
import { parseDiff } from "./diff-parser.mts";
|
|
4
|
-
import { computePatchCoverage } from "./patch-coverage.mts";
|
|
5
|
-
import type { CoverageRule } from "./types.mts";
|
|
6
|
-
|
|
7
|
-
const rules: CoverageRule[] = [
|
|
8
|
-
{ paths: "backend/**", patch_coverage_min: 90 },
|
|
9
|
-
{ paths: "web/**", patch_coverage_min: 5 },
|
|
10
|
-
];
|
|
11
|
-
|
|
12
|
-
describe("computePatchCoverage", () => {
|
|
13
|
-
it("passes when all changed lines are covered", () => {
|
|
14
|
-
const lcov = parseLcov(`SF:backend/foo.mts\nDA:1,1\nDA:2,1\nend_of_record\n`);
|
|
15
|
-
const diff: ReturnType<typeof parseDiff> = new Map([["backend/foo.mts", new Set([1, 2])]]);
|
|
16
|
-
const { buckets } = computePatchCoverage(diff, lcov, rules);
|
|
17
|
-
const bucket = buckets.find((b) => b.rule === "backend/**")!;
|
|
18
|
-
expect(bucket.passed).toBe(true);
|
|
19
|
-
expect(bucket.hit).toBe(2);
|
|
20
|
-
expect(bucket.coverable).toBe(2);
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it("fails when coverage drops below threshold", () => {
|
|
24
|
-
const lcov = parseLcov(
|
|
25
|
-
`SF:backend/foo.mts\nDA:1,1\nDA:2,1\nDA:3,1\nDA:4,1\nDA:5,1\nDA:6,1\nDA:7,1\nDA:8,1\nDA:9,0\nDA:10,0\nend_of_record\n`,
|
|
26
|
-
);
|
|
27
|
-
const diff: ReturnType<typeof parseDiff> = new Map([
|
|
28
|
-
["backend/foo.mts", new Set([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])],
|
|
29
|
-
]);
|
|
30
|
-
const { buckets } = computePatchCoverage(diff, lcov, rules);
|
|
31
|
-
const bucket = buckets.find((b) => b.rule === "backend/**")!;
|
|
32
|
-
expect(bucket.passed).toBe(false);
|
|
33
|
-
expect(bucket.hit).toBe(8);
|
|
34
|
-
expect(bucket.coverable).toBe(10);
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
it("passes vacuously when no coverable changed lines", () => {
|
|
38
|
-
const lcov = parseLcov(`SF:backend/foo.mts\nDA:1,1\nend_of_record\n`);
|
|
39
|
-
const diff: ReturnType<typeof parseDiff> = new Map([["backend/foo.mts", new Set([99])]]);
|
|
40
|
-
const { buckets } = computePatchCoverage(diff, lcov, rules);
|
|
41
|
-
const bucket = buckets.find((b) => b.rule === "backend/**");
|
|
42
|
-
expect(!bucket || bucket.passed).toBe(true);
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
it("skips files not in lcov", () => {
|
|
46
|
-
const lcov = parseLcov(`SF:backend/other.mts\nDA:1,1\nend_of_record\n`);
|
|
47
|
-
const diff: ReturnType<typeof parseDiff> = new Map([
|
|
48
|
-
["backend/missing.mts", new Set([1, 2, 3])],
|
|
49
|
-
]);
|
|
50
|
-
const { buckets, informational } = computePatchCoverage(diff, lcov, rules);
|
|
51
|
-
expect(buckets).toHaveLength(0);
|
|
52
|
-
expect(informational).toHaveLength(0);
|
|
53
|
-
});
|
|
54
|
-
|
|
55
|
-
it("routes unmatched files to informational", () => {
|
|
56
|
-
const lcov = parseLcov(`SF:scripts/ci.mts\nDA:1,0\nend_of_record\n`);
|
|
57
|
-
const diff: ReturnType<typeof parseDiff> = new Map([["scripts/ci.mts", new Set([1])]]);
|
|
58
|
-
const { buckets, informational } = computePatchCoverage(diff, lcov, rules);
|
|
59
|
-
expect(buckets).toHaveLength(0);
|
|
60
|
-
expect(informational).toHaveLength(1);
|
|
61
|
-
expect(informational[0]?.file).toBe("scripts/ci.mts");
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
it("accumulates multiple files into the same bucket", () => {
|
|
65
|
-
// Two backend files → both go into the 'backend/**' bucket
|
|
66
|
-
const lcov = parseLcov(
|
|
67
|
-
`SF:backend/a.mts\nDA:1,1\nDA:2,1\nend_of_record\nSF:backend/b.mts\nDA:10,0\nDA:11,0\nend_of_record\n`,
|
|
68
|
-
);
|
|
69
|
-
const diff: ReturnType<typeof parseDiff> = new Map([
|
|
70
|
-
["backend/a.mts", new Set([1, 2])],
|
|
71
|
-
["backend/b.mts", new Set([10, 11])],
|
|
72
|
-
]);
|
|
73
|
-
const { buckets } = computePatchCoverage(diff, lcov, rules);
|
|
74
|
-
const bucket = buckets.find((b) => b.rule === "backend/**")!;
|
|
75
|
-
expect(bucket.coverable).toBe(4);
|
|
76
|
-
expect(bucket.hit).toBe(2);
|
|
77
|
-
expect(bucket.files).toHaveLength(2);
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it("reports uncovered lines correctly", () => {
|
|
81
|
-
const lcov = parseLcov(
|
|
82
|
-
`SF:backend/svc.mts\nDA:10,1\nDA:11,0\nDA:12,0\nDA:13,1\nend_of_record\n`,
|
|
83
|
-
);
|
|
84
|
-
const diff: ReturnType<typeof parseDiff> = new Map([
|
|
85
|
-
["backend/svc.mts", new Set([10, 11, 12, 13])],
|
|
86
|
-
]);
|
|
87
|
-
const { buckets } = computePatchCoverage(diff, lcov, rules);
|
|
88
|
-
const file = buckets[0]?.files[0]!;
|
|
89
|
-
expect(file.uncoveredLines).toEqual([11, 12]);
|
|
90
|
-
});
|
|
91
|
-
});
|
package/src/report.mts
DELETED
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
import type { BucketResult, CoverageCheckResult, FileCoverageResult } from "./types.mts";
|
|
2
|
-
|
|
3
|
-
export const COMMENT_MARKER = "<!-- coverage-check -->";
|
|
4
|
-
|
|
5
|
-
export function collapseRanges(lines: number[]): string {
|
|
6
|
-
if (lines.length === 0) return "";
|
|
7
|
-
const sorted = [...lines].sort((a, b) => a - b);
|
|
8
|
-
const ranges: string[] = [];
|
|
9
|
-
let start = sorted[0]!;
|
|
10
|
-
let end = start;
|
|
11
|
-
|
|
12
|
-
for (let i = 1; i < sorted.length; i++) {
|
|
13
|
-
const n = sorted[i]!;
|
|
14
|
-
if (n === end + 1) {
|
|
15
|
-
end = n;
|
|
16
|
-
} else {
|
|
17
|
-
ranges.push(start === end ? `L${start}` : `L${start}-${end}`);
|
|
18
|
-
start = n;
|
|
19
|
-
end = n;
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
ranges.push(start === end ? `L${start}` : `L${start}-${end}`);
|
|
23
|
-
return ranges.join(", ");
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function pct(bucket: BucketResult): string {
|
|
27
|
-
if (bucket.coverable === 0) return "—";
|
|
28
|
-
return `${((bucket.hit / bucket.coverable) * 100).toFixed(1)}% (${bucket.hit}/${bucket.coverable})`;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function renderFileList(files: FileCoverageResult[]): string {
|
|
32
|
-
return files
|
|
33
|
-
.filter((f) => f.uncoveredLines.length > 0)
|
|
34
|
-
.map((f) => `- \`${f.file}\`: ${collapseRanges(f.uncoveredLines)}`)
|
|
35
|
-
.join("\n");
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function renderFailureComment(
|
|
39
|
-
result: CoverageCheckResult,
|
|
40
|
-
runUrl: string,
|
|
41
|
-
now: string = new Date().toISOString(),
|
|
42
|
-
): string {
|
|
43
|
-
const failingBuckets = result.buckets.filter((b) => !b.passed);
|
|
44
|
-
const table = [
|
|
45
|
-
"| Workspace rule | Patch coverage | Threshold |",
|
|
46
|
-
"|---|---|---|",
|
|
47
|
-
...failingBuckets.map((b) => `| \`${b.rule}\` | ${pct(b)} | ${b.threshold}% |`),
|
|
48
|
-
].join("\n");
|
|
49
|
-
|
|
50
|
-
const sections = failingBuckets
|
|
51
|
-
.map((b) => {
|
|
52
|
-
const fileList = renderFileList(b.files);
|
|
53
|
-
return `**\`${b.rule}\`** (threshold ${b.threshold}%):\n${fileList || "_No line-level data available_"}`;
|
|
54
|
-
})
|
|
55
|
-
.join("\n\n");
|
|
56
|
-
|
|
57
|
-
const informationalLines = result.informational
|
|
58
|
-
.filter((f) => f.uncoveredLines.length > 0)
|
|
59
|
-
.map((f) => `- \`${f.file}\`: ${collapseRanges(f.uncoveredLines)}`)
|
|
60
|
-
.join("\n");
|
|
61
|
-
|
|
62
|
-
const informationalSection =
|
|
63
|
-
informationalLines.length > 0
|
|
64
|
-
? `\n<details><summary>Informational (no rule)</summary>\n\n${informationalLines}\n</details>`
|
|
65
|
-
: "";
|
|
66
|
-
|
|
67
|
-
return `${COMMENT_MARKER}
|
|
68
|
-
## Patch coverage gate failed
|
|
69
|
-
|
|
70
|
-
${table}
|
|
71
|
-
|
|
72
|
-
### Uncovered lines
|
|
73
|
-
|
|
74
|
-
${sections}
|
|
75
|
-
${informationalSection}
|
|
76
|
-
|
|
77
|
-
_Last updated: ${now} · [Workflow run](${runUrl})_`;
|
|
78
|
-
}
|
package/src/report.test.mts
DELETED
|
@@ -1,142 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { collapseRanges, renderFailureComment, COMMENT_MARKER } from "./report.mts";
|
|
3
|
-
import type { CoverageCheckResult } from "./types.mts";
|
|
4
|
-
|
|
5
|
-
describe("collapseRanges", () => {
|
|
6
|
-
it("returns empty string for empty input", () => {
|
|
7
|
-
expect(collapseRanges([])).toBe("");
|
|
8
|
-
});
|
|
9
|
-
|
|
10
|
-
it("renders a single line", () => {
|
|
11
|
-
expect(collapseRanges([5])).toBe("L5");
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
it("collapses consecutive lines into a range", () => {
|
|
15
|
-
expect(collapseRanges([3, 4, 5])).toBe("L3-5");
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
it("separates non-consecutive lines with commas", () => {
|
|
19
|
-
expect(collapseRanges([3, 4, 7, 9, 10])).toBe("L3-4, L7, L9-10");
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
it("handles unsorted input", () => {
|
|
23
|
-
expect(collapseRanges([10, 1, 2])).toBe("L1-2, L10");
|
|
24
|
-
});
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
describe("renderFailureComment", () => {
|
|
28
|
-
const result: CoverageCheckResult = {
|
|
29
|
-
passed: false,
|
|
30
|
-
buckets: [
|
|
31
|
-
{
|
|
32
|
-
rule: "backend/**",
|
|
33
|
-
threshold: 90,
|
|
34
|
-
coverable: 10,
|
|
35
|
-
hit: 8,
|
|
36
|
-
passed: false,
|
|
37
|
-
files: [
|
|
38
|
-
{
|
|
39
|
-
file: "backend/services/foo.mts",
|
|
40
|
-
coverable: 5,
|
|
41
|
-
hit: 3,
|
|
42
|
-
uncoveredLines: [11, 12],
|
|
43
|
-
rule: "backend/**",
|
|
44
|
-
},
|
|
45
|
-
],
|
|
46
|
-
},
|
|
47
|
-
],
|
|
48
|
-
informational: [],
|
|
49
|
-
};
|
|
50
|
-
|
|
51
|
-
it("includes the marker", () => {
|
|
52
|
-
const comment = renderFailureComment(
|
|
53
|
-
result,
|
|
54
|
-
"https://example.com/run/1",
|
|
55
|
-
"2026-01-01T00:00:00.000Z",
|
|
56
|
-
);
|
|
57
|
-
expect(comment.startsWith(COMMENT_MARKER)).toBe(true);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
it("includes failing bucket in the table", () => {
|
|
61
|
-
const comment = renderFailureComment(
|
|
62
|
-
result,
|
|
63
|
-
"https://example.com/run/1",
|
|
64
|
-
"2026-01-01T00:00:00.000Z",
|
|
65
|
-
);
|
|
66
|
-
expect(comment).toContain("backend/**");
|
|
67
|
-
expect(comment).toContain("90%");
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
it("includes uncovered lines", () => {
|
|
71
|
-
const comment = renderFailureComment(
|
|
72
|
-
result,
|
|
73
|
-
"https://example.com/run/1",
|
|
74
|
-
"2026-01-01T00:00:00.000Z",
|
|
75
|
-
);
|
|
76
|
-
expect(comment).toContain("backend/services/foo.mts");
|
|
77
|
-
expect(comment).toContain("L11-12");
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
it("renders informational section when unmatched files have uncovered lines", () => {
|
|
81
|
-
const resultWithInfo: CoverageCheckResult = {
|
|
82
|
-
...result,
|
|
83
|
-
informational: [
|
|
84
|
-
{ file: "scripts/misc.mts", coverable: 3, hit: 1, uncoveredLines: [4, 5], rule: null },
|
|
85
|
-
],
|
|
86
|
-
};
|
|
87
|
-
const comment = renderFailureComment(
|
|
88
|
-
resultWithInfo,
|
|
89
|
-
"https://example.com/run/1",
|
|
90
|
-
"2026-01-01T00:00:00.000Z",
|
|
91
|
-
);
|
|
92
|
-
expect(comment).toContain("Informational (no rule)");
|
|
93
|
-
expect(comment).toContain("scripts/misc.mts");
|
|
94
|
-
expect(comment).toContain("L4-5");
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
it("renders — when bucket has no coverable lines", () => {
|
|
98
|
-
const resultNoCoverable: CoverageCheckResult = {
|
|
99
|
-
passed: false,
|
|
100
|
-
buckets: [
|
|
101
|
-
{
|
|
102
|
-
rule: "backend/**",
|
|
103
|
-
threshold: 90,
|
|
104
|
-
coverable: 0,
|
|
105
|
-
hit: 0,
|
|
106
|
-
passed: false,
|
|
107
|
-
files: [],
|
|
108
|
-
},
|
|
109
|
-
],
|
|
110
|
-
informational: [],
|
|
111
|
-
};
|
|
112
|
-
const comment = renderFailureComment(resultNoCoverable, "N/A", "2026-01-01T00:00:00.000Z");
|
|
113
|
-
expect(comment).toContain("—");
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
it("renders _No line-level data available_ when bucket files have no uncovered lines", () => {
|
|
117
|
-
const resultNoLines: CoverageCheckResult = {
|
|
118
|
-
passed: false,
|
|
119
|
-
buckets: [
|
|
120
|
-
{
|
|
121
|
-
rule: "backend/**",
|
|
122
|
-
threshold: 90,
|
|
123
|
-
coverable: 5,
|
|
124
|
-
hit: 4,
|
|
125
|
-
passed: false,
|
|
126
|
-
files: [
|
|
127
|
-
{
|
|
128
|
-
file: "backend/foo.mts",
|
|
129
|
-
coverable: 5,
|
|
130
|
-
hit: 4,
|
|
131
|
-
uncoveredLines: [],
|
|
132
|
-
rule: "backend/**",
|
|
133
|
-
},
|
|
134
|
-
],
|
|
135
|
-
},
|
|
136
|
-
],
|
|
137
|
-
informational: [],
|
|
138
|
-
};
|
|
139
|
-
const comment = renderFailureComment(resultNoLines, "N/A", "2026-01-01T00:00:00.000Z");
|
|
140
|
-
expect(comment).toContain("_No line-level data available_");
|
|
141
|
-
});
|
|
142
|
-
});
|
package/src/rules.mts
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
/* c8 ignore next */
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
3
|
-
import { matchesGlob } from "node:path";
|
|
4
|
-
import yaml from "js-yaml";
|
|
5
|
-
import type { CoverageRule, CoverageRules } from "./types.mts";
|
|
6
|
-
|
|
7
|
-
export function loadRules(rulesPath: string): CoverageRule[] {
|
|
8
|
-
const text = readFileSync(rulesPath, "utf8");
|
|
9
|
-
const parsed = yaml.load(text) as CoverageRules;
|
|
10
|
-
if (!Array.isArray(parsed?.rules)) {
|
|
11
|
-
throw new Error(`${rulesPath}: expected a 'rules' array`);
|
|
12
|
-
}
|
|
13
|
-
for (let i = 0; i < parsed.rules.length; i++) {
|
|
14
|
-
const rule = parsed.rules[i] as Partial<CoverageRule>;
|
|
15
|
-
if (typeof rule?.paths !== "string") {
|
|
16
|
-
throw new Error(`${rulesPath}: rule[${i}].paths must be a string`);
|
|
17
|
-
}
|
|
18
|
-
const min = rule.patch_coverage_min;
|
|
19
|
-
if (!Number.isFinite(min) || (min as number) < 0 || (min as number) > 100) {
|
|
20
|
-
throw new Error(
|
|
21
|
-
`${rulesPath}: rule[${i}].patch_coverage_min must be a number between 0 and 100`,
|
|
22
|
-
);
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
return parsed.rules;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** Returns the first matching rule for a repo-root-relative file path, or null. */
|
|
29
|
-
export function matchRule(file: string, rules: CoverageRule[]): CoverageRule | null {
|
|
30
|
-
for (const rule of rules) {
|
|
31
|
-
if (matchesGlob(file, rule.paths)) return rule;
|
|
32
|
-
}
|
|
33
|
-
return null;
|
|
34
|
-
}
|
package/src/rules.test.mts
DELETED
|
@@ -1,98 +0,0 @@
|
|
|
1
|
-
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
5
|
-
import { loadRules, matchRule } from "./rules.mts";
|
|
6
|
-
import type { CoverageRule } from "./types.mts";
|
|
7
|
-
|
|
8
|
-
const rules: CoverageRule[] = [
|
|
9
|
-
{ paths: "cloudflare-worker/**", patch_coverage_min: 100 },
|
|
10
|
-
{ paths: "lambdas/**", patch_coverage_min: 100 },
|
|
11
|
-
{ paths: "web/lib/api/**", patch_coverage_min: 100 },
|
|
12
|
-
{ paths: "backend/**", patch_coverage_min: 90 },
|
|
13
|
-
{ paths: "web/**", patch_coverage_min: 5 },
|
|
14
|
-
];
|
|
15
|
-
|
|
16
|
-
describe("matchRule", () => {
|
|
17
|
-
it("matches cloudflare-worker files", () => {
|
|
18
|
-
expect(matchRule("cloudflare-worker/src/index.mts", rules)?.patch_coverage_min).toBe(100);
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
it("matches the more-specific web/lib/api/** before web/**", () => {
|
|
22
|
-
expect(matchRule("web/lib/api/client.mts", rules)?.patch_coverage_min).toBe(100);
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
it("matches web/** for non-api web files", () => {
|
|
26
|
-
expect(matchRule("web/components/Foo.tsx", rules)?.patch_coverage_min).toBe(5);
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
it("matches backend/**", () => {
|
|
30
|
-
expect(matchRule("backend/services/foo.mts", rules)?.patch_coverage_min).toBe(90);
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
it("returns null for unmatched paths", () => {
|
|
34
|
-
expect(matchRule("ci/ci-local.mts", rules)).toBeNull();
|
|
35
|
-
expect(matchRule("docs/README.md", rules)).toBeNull();
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it("matches lambdas/**", () => {
|
|
39
|
-
expect(matchRule("lambdas/handler.mts", rules)?.patch_coverage_min).toBe(100);
|
|
40
|
-
});
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
describe("loadRules", () => {
|
|
44
|
-
let tmpDir: string;
|
|
45
|
-
|
|
46
|
-
beforeEach(() => {
|
|
47
|
-
tmpDir = mkdtempSync(join(tmpdir(), "coverage-check-rules-"));
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
afterEach(() => {
|
|
51
|
-
rmSync(tmpDir, { recursive: true, force: true });
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
function write(name: string, content: string) {
|
|
55
|
-
const path = join(tmpDir, name);
|
|
56
|
-
writeFileSync(path, content, "utf8");
|
|
57
|
-
return path;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
it("loads a valid rules file", () => {
|
|
61
|
-
const path = write("rules.yml", "rules:\n - paths: backend/**\n patch_coverage_min: 90\n");
|
|
62
|
-
expect(loadRules(path)).toEqual([{ paths: "backend/**", patch_coverage_min: 90 }]);
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
it("throws when rules is not an array", () => {
|
|
66
|
-
const path = write("rules.yml", "rules: not-an-array\n");
|
|
67
|
-
expect(() => loadRules(path)).toThrow("expected a 'rules' array");
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
it("throws when a rule paths field is missing", () => {
|
|
71
|
-
const path = write("rules.yml", "rules:\n - patch_coverage_min: 90\n");
|
|
72
|
-
expect(() => loadRules(path)).toThrow("rule[0].paths must be a string");
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
it("throws when patch_coverage_min is out of range", () => {
|
|
76
|
-
const path = write("rules.yml", "rules:\n - paths: backend/**\n patch_coverage_min: 150\n");
|
|
77
|
-
expect(() => loadRules(path)).toThrow(
|
|
78
|
-
"rule[0].patch_coverage_min must be a number between 0 and 100",
|
|
79
|
-
);
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it("throws when patch_coverage_min is not a number", () => {
|
|
83
|
-
const path = write(
|
|
84
|
-
"rules.yml",
|
|
85
|
-
"rules:\n - paths: backend/**\n patch_coverage_min: 'high'\n",
|
|
86
|
-
);
|
|
87
|
-
expect(() => loadRules(path)).toThrow(
|
|
88
|
-
"rule[0].patch_coverage_min must be a number between 0 and 100",
|
|
89
|
-
);
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
it("throws when patch_coverage_min is negative", () => {
|
|
93
|
-
const path = write("rules.yml", "rules:\n - paths: backend/**\n patch_coverage_min: -1\n");
|
|
94
|
-
expect(() => loadRules(path)).toThrow(
|
|
95
|
-
"rule[0].patch_coverage_min must be a number between 0 and 100",
|
|
96
|
-
);
|
|
97
|
-
});
|
|
98
|
-
});
|
package/src/s3-suite-store.mts
DELETED
|
@@ -1,138 +0,0 @@
|
|
|
1
|
-
import { buffer } from "node:stream/consumers";
|
|
2
|
-
import { Readable } from "node:stream";
|
|
3
|
-
import {
|
|
4
|
-
GetObjectCommand,
|
|
5
|
-
ListObjectsV2Command,
|
|
6
|
-
PutObjectCommand,
|
|
7
|
-
S3Client,
|
|
8
|
-
} from "@aws-sdk/client-s3";
|
|
9
|
-
import { assertSafePathComponent } from "./suite-store.mts";
|
|
10
|
-
import type { SuiteMeta, SuiteStore } from "./suite-store.mts";
|
|
11
|
-
|
|
12
|
-
type ClientLike = { send(cmd: object): Promise<unknown> };
|
|
13
|
-
|
|
14
|
-
export type S3SuiteStoreOptions = {
|
|
15
|
-
bucket: string;
|
|
16
|
-
prefix?: string;
|
|
17
|
-
region?: string;
|
|
18
|
-
/** Inject a custom S3 client (e.g. for testing). */
|
|
19
|
-
client?: ClientLike;
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
export class S3SuiteStore implements SuiteStore {
|
|
23
|
-
private readonly bucket: string;
|
|
24
|
-
private readonly prefix: string;
|
|
25
|
-
private readonly client: ClientLike;
|
|
26
|
-
|
|
27
|
-
constructor({ bucket, prefix, region, client }: S3SuiteStoreOptions) {
|
|
28
|
-
this.bucket = bucket;
|
|
29
|
-
this.prefix = prefix ? prefix.replace(/\/+$/, "") : "";
|
|
30
|
-
this.client = client ?? new S3Client({ region });
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
private key(...parts: string[]): string {
|
|
34
|
-
return this.prefix ? [this.prefix, ...parts].join("/") : parts.join("/");
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
async list(): Promise<string[]> {
|
|
38
|
-
const pfx = this.prefix ? `${this.prefix}/` : "";
|
|
39
|
-
const suites: string[] = [];
|
|
40
|
-
let continuationToken: string | undefined;
|
|
41
|
-
do {
|
|
42
|
-
const resp = (await this.client.send(
|
|
43
|
-
new ListObjectsV2Command({
|
|
44
|
-
Bucket: this.bucket,
|
|
45
|
-
Prefix: pfx,
|
|
46
|
-
Delimiter: "/",
|
|
47
|
-
...(continuationToken ? { ContinuationToken: continuationToken } : {}),
|
|
48
|
-
}),
|
|
49
|
-
)) as {
|
|
50
|
-
CommonPrefixes?: { Prefix?: string }[];
|
|
51
|
-
IsTruncated?: boolean;
|
|
52
|
-
NextContinuationToken?: string;
|
|
53
|
-
};
|
|
54
|
-
suites.push(
|
|
55
|
-
...(resp.CommonPrefixes ?? [])
|
|
56
|
-
.map((cp) => cp.Prefix?.replace(pfx, "").replace(/\/$/, "") ?? "")
|
|
57
|
-
.filter(Boolean),
|
|
58
|
-
);
|
|
59
|
-
continuationToken = resp.IsTruncated ? resp.NextContinuationToken : undefined;
|
|
60
|
-
} while (continuationToken);
|
|
61
|
-
return suites;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
async get(suite: string, opts?: { sha?: string; branch?: string }): Promise<Buffer | null> {
|
|
65
|
-
assertSafePathComponent(suite, "suite");
|
|
66
|
-
if (opts?.sha !== undefined) assertSafePathComponent(opts.sha, "sha");
|
|
67
|
-
let sha = opts?.sha;
|
|
68
|
-
if (!sha) {
|
|
69
|
-
const branch = opts?.branch ?? "main";
|
|
70
|
-
assertSafePathComponent(branch, "branch");
|
|
71
|
-
try {
|
|
72
|
-
const resp = (await this.client.send(
|
|
73
|
-
new GetObjectCommand({
|
|
74
|
-
Bucket: this.bucket,
|
|
75
|
-
Key: this.key(suite, "branch", branch, "latest.json"),
|
|
76
|
-
}),
|
|
77
|
-
)) as { Body: unknown };
|
|
78
|
-
const body = await bodyToBuffer(resp.Body);
|
|
79
|
-
const parsed = (JSON.parse(body.toString("utf8")) as { sha: string }).sha;
|
|
80
|
-
assertSafePathComponent(parsed, "sha");
|
|
81
|
-
sha = parsed;
|
|
82
|
-
} catch (err) {
|
|
83
|
-
if (isNotFound(err)) return null;
|
|
84
|
-
throw err;
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
try {
|
|
88
|
-
const resp = (await this.client.send(
|
|
89
|
-
new GetObjectCommand({
|
|
90
|
-
Bucket: this.bucket,
|
|
91
|
-
Key: this.key(suite, "sha", sha, "lcov.info"),
|
|
92
|
-
}),
|
|
93
|
-
)) as { Body: unknown };
|
|
94
|
-
return bodyToBuffer(resp.Body);
|
|
95
|
-
} catch (err) {
|
|
96
|
-
if (isNotFound(err)) return null;
|
|
97
|
-
throw err;
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
async put(
|
|
102
|
-
suite: string,
|
|
103
|
-
lcov: Buffer,
|
|
104
|
-
meta: SuiteMeta & { sha: string; branch: string },
|
|
105
|
-
): Promise<void> {
|
|
106
|
-
assertSafePathComponent(suite, "suite");
|
|
107
|
-
assertSafePathComponent(meta.sha, "sha");
|
|
108
|
-
assertSafePathComponent(meta.branch, "branch");
|
|
109
|
-
const ts = meta.timestamp ?? new Date().toISOString();
|
|
110
|
-
await this.client.send(
|
|
111
|
-
new PutObjectCommand({
|
|
112
|
-
Bucket: this.bucket,
|
|
113
|
-
Key: this.key(suite, "sha", meta.sha, "lcov.info"),
|
|
114
|
-
Body: lcov,
|
|
115
|
-
ContentType: "text/plain",
|
|
116
|
-
}),
|
|
117
|
-
);
|
|
118
|
-
await this.client.send(
|
|
119
|
-
new PutObjectCommand({
|
|
120
|
-
Bucket: this.bucket,
|
|
121
|
-
Key: this.key(suite, "branch", meta.branch, "latest.json"),
|
|
122
|
-
Body: Buffer.from(JSON.stringify({ sha: meta.sha, timestamp: ts }), "utf8"),
|
|
123
|
-
ContentType: "application/json",
|
|
124
|
-
}),
|
|
125
|
-
);
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
function isNotFound(err: unknown): boolean {
|
|
130
|
-
return err instanceof Error && (err.name === "NoSuchKey" || err.name === "NotFound");
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
async function bodyToBuffer(body: unknown): Promise<Buffer> {
|
|
134
|
-
if (body instanceof Readable) return buffer(body);
|
|
135
|
-
if (body instanceof Uint8Array) return Buffer.from(body);
|
|
136
|
-
if (body instanceof Blob) return Buffer.from(await body.arrayBuffer());
|
|
137
|
-
throw new Error("unexpected S3 response body type");
|
|
138
|
-
}
|