coverage-check 0.1.0 → 0.2.1
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 +105 -51
- package/package.json +2 -1
- package/src/commands/check-args.mts +110 -0
- package/src/commands/check.mts +41 -94
- package/src/commands/check.test.mts +253 -25
- package/src/commands/store-put.mts +37 -22
- package/src/commands/store-put.test.mts +117 -23
- package/src/coverage-check.mts +2 -0
- package/src/github-comment.mts +8 -3
- package/src/github-comment.test.mts +4 -5
- package/src/report.mts +0 -9
- package/src/report.test.mts +1 -18
- package/src/s3-suite-store.mts +138 -0
- package/src/s3-suite-store.test.mts +308 -0
- package/src/step-summary.mts +89 -0
- package/src/step-summary.test.mts +189 -0
- package/src/store-factory.mts +23 -0
- package/src/store-factory.test.mts +67 -0
- package/src/suite-store.mts +67 -17
- package/src/suite-store.test.mts +124 -30
- package/src/types.mts +1 -1
package/src/suite-store.mts
CHANGED
|
@@ -2,26 +2,46 @@ import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import type { SuiteMeta } from "./types.mts";
|
|
4
4
|
|
|
5
|
+
export function assertSafePathComponent(value: string, label: string): void {
|
|
6
|
+
if (
|
|
7
|
+
typeof value !== "string" ||
|
|
8
|
+
value.length === 0 ||
|
|
9
|
+
value === "." ||
|
|
10
|
+
value === ".." ||
|
|
11
|
+
value.includes("/") ||
|
|
12
|
+
value.includes("\\")
|
|
13
|
+
) {
|
|
14
|
+
throw new Error(`invalid ${label}: ${JSON.stringify(value)}`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
5
18
|
export type { SuiteMeta };
|
|
6
19
|
|
|
7
20
|
export interface SuiteStore {
|
|
8
21
|
/** Returns all suite names currently in the store. */
|
|
9
22
|
list(): Promise<string[]>;
|
|
10
|
-
/**
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
23
|
+
/**
|
|
24
|
+
* Returns the merged LCOV bytes for a suite, or null if absent.
|
|
25
|
+
* Resolves by sha if opts.sha is set; otherwise follows the branch pointer
|
|
26
|
+
* (opts.branch, defaulting to "main").
|
|
27
|
+
*/
|
|
28
|
+
get(suite: string, opts?: { sha?: string; branch?: string }): Promise<Buffer | null>;
|
|
29
|
+
/** Stores the merged LCOV bytes for a suite. sha and branch are required. */
|
|
30
|
+
put(
|
|
31
|
+
suite: string,
|
|
32
|
+
lcov: Buffer,
|
|
33
|
+
meta: SuiteMeta & { sha: string; branch: string },
|
|
34
|
+
): Promise<void>;
|
|
14
35
|
}
|
|
15
36
|
|
|
16
37
|
/**
|
|
17
38
|
* Filesystem-backed SuiteStore.
|
|
18
39
|
*
|
|
19
40
|
* Layout:
|
|
20
|
-
* <root>/<suite>/lcov.info
|
|
21
|
-
* <root>/<suite>/
|
|
41
|
+
* <root>/<suite>/sha/<sha>/lcov.info — LCOV payload
|
|
42
|
+
* <root>/<suite>/branch/<branch>/latest.json — { sha, timestamp }
|
|
22
43
|
*
|
|
23
|
-
* Transport is the caller's responsibility (e.g. git orphan branch
|
|
24
|
-
* GitHub Actions cache). This class only reads/writes local files.
|
|
44
|
+
* Transport is the caller's responsibility (e.g. S3 sync, git orphan branch).
|
|
25
45
|
*/
|
|
26
46
|
export class FileSystemSuiteStore implements SuiteStore {
|
|
27
47
|
private readonly root: string;
|
|
@@ -40,23 +60,53 @@ export class FileSystemSuiteStore implements SuiteStore {
|
|
|
40
60
|
}
|
|
41
61
|
}
|
|
42
62
|
|
|
43
|
-
async get(suite: string): Promise<Buffer | null> {
|
|
44
|
-
|
|
63
|
+
async get(suite: string, opts?: { sha?: string; branch?: string }): Promise<Buffer | null> {
|
|
64
|
+
assertSafePathComponent(suite, "suite");
|
|
65
|
+
if (opts?.sha !== undefined) assertSafePathComponent(opts.sha, "sha");
|
|
66
|
+
let sha = opts?.sha;
|
|
67
|
+
if (!sha) {
|
|
68
|
+
const branch = opts?.branch ?? "main";
|
|
69
|
+
assertSafePathComponent(branch, "branch");
|
|
70
|
+
const pointerPath = join(this.root, suite, "branch", branch, "latest.json");
|
|
71
|
+
try {
|
|
72
|
+
const pointer = JSON.parse(readFileSync(pointerPath, "utf8")) as { sha: string };
|
|
73
|
+
assertSafePathComponent(pointer.sha, "sha");
|
|
74
|
+
sha = pointer.sha;
|
|
75
|
+
} catch (err) {
|
|
76
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
77
|
+
throw err;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const lcovPath = join(this.root, suite, "sha", sha, "lcov.info");
|
|
45
81
|
try {
|
|
46
|
-
return readFileSync(
|
|
82
|
+
return readFileSync(lcovPath);
|
|
47
83
|
} catch (err) {
|
|
48
84
|
if ((err as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
49
85
|
throw err;
|
|
50
86
|
}
|
|
51
87
|
}
|
|
52
88
|
|
|
53
|
-
async put(
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
89
|
+
async put(
|
|
90
|
+
suite: string,
|
|
91
|
+
lcov: Buffer,
|
|
92
|
+
meta: SuiteMeta & { sha: string; branch: string },
|
|
93
|
+
): Promise<void> {
|
|
94
|
+
assertSafePathComponent(suite, "suite");
|
|
95
|
+
assertSafePathComponent(meta.sha, "sha");
|
|
96
|
+
assertSafePathComponent(meta.branch, "branch");
|
|
97
|
+
const shaDir = join(this.root, suite, "sha", meta.sha);
|
|
98
|
+
mkdirSync(shaDir, { recursive: true });
|
|
99
|
+
writeFileSync(join(shaDir, "lcov.info"), lcov);
|
|
100
|
+
|
|
101
|
+
const branchDir = join(this.root, suite, "branch", meta.branch);
|
|
102
|
+
mkdirSync(branchDir, { recursive: true });
|
|
57
103
|
writeFileSync(
|
|
58
|
-
join(
|
|
59
|
-
JSON.stringify(
|
|
104
|
+
join(branchDir, "latest.json"),
|
|
105
|
+
JSON.stringify(
|
|
106
|
+
{ sha: meta.sha, timestamp: meta.timestamp ?? new Date().toISOString() },
|
|
107
|
+
null,
|
|
108
|
+
2,
|
|
109
|
+
),
|
|
60
110
|
);
|
|
61
111
|
}
|
|
62
112
|
}
|
package/src/suite-store.test.mts
CHANGED
|
@@ -2,7 +2,7 @@ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
5
|
-
import { FileSystemSuiteStore } from "./suite-store.mts";
|
|
5
|
+
import { assertSafePathComponent, FileSystemSuiteStore } from "./suite-store.mts";
|
|
6
6
|
|
|
7
7
|
describe("FileSystemSuiteStore", () => {
|
|
8
8
|
let tmpDir: string;
|
|
@@ -28,8 +28,14 @@ describe("FileSystemSuiteStore", () => {
|
|
|
28
28
|
});
|
|
29
29
|
|
|
30
30
|
it("returns suite names after putting suites", async () => {
|
|
31
|
-
await store.put("backend", Buffer.from("SF:foo\nend_of_record\n")
|
|
32
|
-
|
|
31
|
+
await store.put("backend", Buffer.from("SF:foo\nend_of_record\n"), {
|
|
32
|
+
sha: "abc",
|
|
33
|
+
branch: "main",
|
|
34
|
+
});
|
|
35
|
+
await store.put("frontend", Buffer.from("SF:bar\nend_of_record\n"), {
|
|
36
|
+
sha: "def",
|
|
37
|
+
branch: "main",
|
|
38
|
+
});
|
|
33
39
|
const suites = await store.list();
|
|
34
40
|
expect(suites).toContain("backend");
|
|
35
41
|
expect(suites).toContain("frontend");
|
|
@@ -37,8 +43,10 @@ describe("FileSystemSuiteStore", () => {
|
|
|
37
43
|
});
|
|
38
44
|
|
|
39
45
|
it("ignores non-directory entries in the root", async () => {
|
|
40
|
-
await store.put("backend", Buffer.from("SF:foo\nend_of_record\n")
|
|
41
|
-
|
|
46
|
+
await store.put("backend", Buffer.from("SF:foo\nend_of_record\n"), {
|
|
47
|
+
sha: "abc",
|
|
48
|
+
branch: "main",
|
|
49
|
+
});
|
|
42
50
|
const suites = await store.list();
|
|
43
51
|
expect(suites).not.toContain("lcov.info");
|
|
44
52
|
expect(suites).not.toContain("meta.json");
|
|
@@ -51,65 +59,151 @@ describe("FileSystemSuiteStore", () => {
|
|
|
51
59
|
});
|
|
52
60
|
|
|
53
61
|
describe("get()", () => {
|
|
54
|
-
it("returns null for a missing suite", async () => {
|
|
62
|
+
it("returns null for a missing suite (no pointer file)", async () => {
|
|
55
63
|
expect(await store.get("nonexistent")).toBeNull();
|
|
56
64
|
});
|
|
57
65
|
|
|
58
|
-
it("returns
|
|
66
|
+
it("returns null when get() is called with explicit sha that has no lcov file", async () => {
|
|
67
|
+
// Pointer exists (from a different sha), but requested sha is absent
|
|
68
|
+
await store.put("backend", Buffer.from("SF:backend/foo.mts\nDA:1,1\nend_of_record\n"), {
|
|
69
|
+
sha: "abc",
|
|
70
|
+
branch: "main",
|
|
71
|
+
});
|
|
72
|
+
expect(await store.get("backend", { sha: "nonexistent-sha" })).toBeNull();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("returns the LCOV buffer after put() via branch pointer", async () => {
|
|
59
76
|
const lcov = Buffer.from("SF:backend/foo.mts\nDA:1,1\nend_of_record\n");
|
|
60
|
-
await store.put("backend", lcov);
|
|
77
|
+
await store.put("backend", lcov, { sha: "abc123", branch: "main" });
|
|
61
78
|
const result = await store.get("backend");
|
|
62
79
|
expect(result).not.toBeNull();
|
|
63
80
|
expect(result!.toString()).toBe(lcov.toString());
|
|
64
81
|
});
|
|
65
82
|
|
|
66
|
-
it("
|
|
83
|
+
it("returns the LCOV buffer when get() is called with explicit sha", async () => {
|
|
84
|
+
const lcov = Buffer.from("SF:backend/foo.mts\nDA:1,1\nend_of_record\n");
|
|
85
|
+
await store.put("backend", lcov, { sha: "abc123", branch: "main" });
|
|
86
|
+
const result = await store.get("backend", { sha: "abc123" });
|
|
87
|
+
expect(result).not.toBeNull();
|
|
88
|
+
expect(result!.toString()).toBe(lcov.toString());
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("resolves the correct sha via the branch pointer", async () => {
|
|
92
|
+
const lcovV1 = Buffer.from("SF:backend/v1.mts\nDA:1,1\nend_of_record\n");
|
|
93
|
+
const lcovV2 = Buffer.from("SF:backend/v2.mts\nDA:2,1\nend_of_record\n");
|
|
94
|
+
await store.put("backend", lcovV1, { sha: "sha1", branch: "main" });
|
|
95
|
+
await store.put("backend", lcovV2, { sha: "sha2", branch: "main" });
|
|
96
|
+
// Branch pointer now points to sha2; sha1 still exists on disk
|
|
97
|
+
const result = await store.get("backend", { branch: "main" });
|
|
98
|
+
expect(result!.toString()).toBe(lcovV2.toString());
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("rethrows non-ENOENT errors from pointer readFileSync", async () => {
|
|
67
102
|
const badStore = new FileSystemSuiteStore("\0invalid");
|
|
68
103
|
await expect(badStore.get("suite")).rejects.toThrow();
|
|
69
104
|
});
|
|
105
|
+
|
|
106
|
+
it("rethrows non-ENOENT errors from lcov readFileSync when sha is explicit", async () => {
|
|
107
|
+
const badStore = new FileSystemSuiteStore("\0invalid");
|
|
108
|
+
await expect(badStore.get("suite", { sha: "abc" })).rejects.toThrow();
|
|
109
|
+
});
|
|
70
110
|
});
|
|
71
111
|
|
|
72
112
|
describe("put()", () => {
|
|
73
|
-
it("writes lcov.info and
|
|
113
|
+
it("writes lcov.info under sha/ and latest.json under branch/", async () => {
|
|
74
114
|
const lcov = Buffer.from("SF:foo.mts\nDA:1,5\nend_of_record\n");
|
|
75
|
-
await store.put("backend", lcov, { sha: "abc123",
|
|
115
|
+
await store.put("backend", lcov, { sha: "abc123", branch: "main" });
|
|
76
116
|
|
|
77
|
-
const lcovPath = join(tmpDir, "backend", "lcov.info");
|
|
78
|
-
const
|
|
117
|
+
const lcovPath = join(tmpDir, "backend", "sha", "abc123", "lcov.info");
|
|
118
|
+
const pointerPath = join(tmpDir, "backend", "branch", "main", "latest.json");
|
|
79
119
|
expect(readFileSync(lcovPath).toString()).toBe(lcov.toString());
|
|
80
120
|
|
|
81
|
-
const
|
|
82
|
-
expect(
|
|
83
|
-
expect(
|
|
84
|
-
expect(typeof meta.timestamp).toBe("string");
|
|
121
|
+
const pointer = JSON.parse(readFileSync(pointerPath, "utf8"));
|
|
122
|
+
expect(pointer.sha).toBe("abc123");
|
|
123
|
+
expect(typeof pointer.timestamp).toBe("string");
|
|
85
124
|
});
|
|
86
125
|
|
|
87
|
-
it("uses the provided timestamp
|
|
88
|
-
await store.put("backend", Buffer.from(""), {
|
|
89
|
-
|
|
90
|
-
|
|
126
|
+
it("uses the provided timestamp", async () => {
|
|
127
|
+
await store.put("backend", Buffer.from(""), {
|
|
128
|
+
sha: "abc",
|
|
129
|
+
branch: "main",
|
|
130
|
+
timestamp: "2026-01-01T00:00:00.000Z",
|
|
131
|
+
});
|
|
132
|
+
const pointer = JSON.parse(
|
|
133
|
+
readFileSync(join(tmpDir, "backend", "branch", "main", "latest.json"), "utf8"),
|
|
134
|
+
);
|
|
135
|
+
expect(pointer.timestamp).toBe("2026-01-01T00:00:00.000Z");
|
|
91
136
|
});
|
|
92
137
|
|
|
93
138
|
it("creates a default timestamp when none is provided", async () => {
|
|
94
139
|
const before = new Date().toISOString();
|
|
95
|
-
await store.put("backend", Buffer.from(""));
|
|
96
|
-
const meta = JSON.parse(readFileSync(join(tmpDir, "backend", "meta.json"), "utf8"));
|
|
140
|
+
await store.put("backend", Buffer.from(""), { sha: "abc", branch: "main" });
|
|
97
141
|
const after = new Date().toISOString();
|
|
98
|
-
|
|
99
|
-
|
|
142
|
+
const pointer = JSON.parse(
|
|
143
|
+
readFileSync(join(tmpDir, "backend", "branch", "main", "latest.json"), "utf8"),
|
|
144
|
+
);
|
|
145
|
+
expect(pointer.timestamp >= before).toBe(true);
|
|
146
|
+
expect(pointer.timestamp <= after).toBe(true);
|
|
100
147
|
});
|
|
101
148
|
|
|
102
149
|
it("creates parent directories recursively", async () => {
|
|
103
150
|
const nested = new FileSystemSuiteStore(join(tmpDir, "deep", "nested", "store"));
|
|
104
|
-
await nested.put("backend", Buffer.from("SF:foo\nend_of_record\n")
|
|
151
|
+
await nested.put("backend", Buffer.from("SF:foo\nend_of_record\n"), {
|
|
152
|
+
sha: "abc",
|
|
153
|
+
branch: "main",
|
|
154
|
+
});
|
|
105
155
|
expect(await nested.list()).toContain("backend");
|
|
106
156
|
});
|
|
107
157
|
|
|
108
|
-
it("overwrites an existing suite", async () => {
|
|
109
|
-
await store.put("backend", Buffer.from("v1"));
|
|
110
|
-
await store.put("backend", Buffer.from("v2"));
|
|
111
|
-
const result = await store.get("backend");
|
|
158
|
+
it("overwrites an existing suite when same sha is used", async () => {
|
|
159
|
+
await store.put("backend", Buffer.from("v1"), { sha: "abc", branch: "main" });
|
|
160
|
+
await store.put("backend", Buffer.from("v2"), { sha: "abc", branch: "main" });
|
|
161
|
+
const result = await store.get("backend", { sha: "abc" });
|
|
112
162
|
expect(result!.toString()).toBe("v2");
|
|
113
163
|
});
|
|
164
|
+
|
|
165
|
+
it("keeps multiple sha entries independently", async () => {
|
|
166
|
+
await store.put("backend", Buffer.from("v1"), { sha: "sha1", branch: "main" });
|
|
167
|
+
await store.put("backend", Buffer.from("v2"), { sha: "sha2", branch: "main" });
|
|
168
|
+
expect((await store.get("backend", { sha: "sha1" }))!.toString()).toBe("v1");
|
|
169
|
+
expect((await store.get("backend", { sha: "sha2" }))!.toString()).toBe("v2");
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe("path traversal protection", () => {
|
|
174
|
+
const invalid = ["", ".", "..", "a/b", "a\\b"];
|
|
175
|
+
for (const val of invalid) {
|
|
176
|
+
it(`get() rejects suite=${JSON.stringify(val)}`, async () => {
|
|
177
|
+
await expect(store.get(val)).rejects.toThrow("invalid suite");
|
|
178
|
+
});
|
|
179
|
+
it(`get() rejects branch=${JSON.stringify(val)}`, async () => {
|
|
180
|
+
await expect(store.get("backend", { branch: val })).rejects.toThrow("invalid branch");
|
|
181
|
+
});
|
|
182
|
+
it(`get() rejects sha=${JSON.stringify(val)}`, async () => {
|
|
183
|
+
await expect(store.get("backend", { sha: val })).rejects.toThrow("invalid sha");
|
|
184
|
+
});
|
|
185
|
+
it(`put() rejects suite=${JSON.stringify(val)}`, async () => {
|
|
186
|
+
await expect(
|
|
187
|
+
store.put(val, Buffer.from(""), { sha: "abc", branch: "main" }),
|
|
188
|
+
).rejects.toThrow("invalid suite");
|
|
189
|
+
});
|
|
190
|
+
it(`put() rejects sha=${JSON.stringify(val)}`, async () => {
|
|
191
|
+
await expect(
|
|
192
|
+
store.put("backend", Buffer.from(""), { sha: val, branch: "main" }),
|
|
193
|
+
).rejects.toThrow("invalid sha");
|
|
194
|
+
});
|
|
195
|
+
it(`put() rejects branch=${JSON.stringify(val)}`, async () => {
|
|
196
|
+
await expect(
|
|
197
|
+
store.put("backend", Buffer.from(""), { sha: "abc", branch: val }),
|
|
198
|
+
).rejects.toThrow("invalid branch");
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe("assertSafePathComponent", () => {
|
|
205
|
+
it("rejects non-string values at runtime (e.g. from JSON.parse)", () => {
|
|
206
|
+
expect(() => assertSafePathComponent(123 as unknown as string, "sha")).toThrow("invalid sha");
|
|
207
|
+
expect(() => assertSafePathComponent(null as unknown as string, "sha")).toThrow("invalid sha");
|
|
114
208
|
});
|
|
115
209
|
});
|