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.
@@ -1,21 +1,91 @@
1
1
  import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { tmpdir } from "node:os";
4
- import { afterEach, beforeEach, describe, expect, it } from "vitest";
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
5
  import { main, runStorePut } from "./store-put.mts";
6
6
  import { FileSystemSuiteStore } from "../suite-store.mts";
7
7
 
8
8
  describe("main argument parsing", () => {
9
9
  it("returns 2 when --suite is missing", async () => {
10
- expect(await main(["--store", "/tmp/store"])).toBe(2);
10
+ expect(await main(["--store", "/tmp/store", "--sha", "abc", "--branch", "main"])).toBe(2);
11
11
  });
12
12
 
13
13
  it("returns 2 when --store is missing", async () => {
14
- expect(await main(["--suite", "backend"])).toBe(2);
14
+ expect(await main(["--suite", "backend", "--sha", "abc", "--branch", "main"])).toBe(2);
15
+ });
16
+
17
+ it("returns 2 when --sha is missing", async () => {
18
+ expect(await main(["--suite", "backend", "--store", "/tmp/s", "--branch", "main"])).toBe(2);
19
+ });
20
+
21
+ it("returns 2 when --branch is missing", async () => {
22
+ expect(await main(["--suite", "backend", "--store", "/tmp/s", "--sha", "abc"])).toBe(2);
23
+ });
24
+
25
+ it("returns 2 when a flag token follows as the value (e.g. --suite --store)", async () => {
26
+ expect(await main(["--suite", "--store"])).toBe(2);
27
+ });
28
+
29
+ it("accepts --store-s3 flag (returns 2 when no lcov files, not unknown-flag error)", async () => {
30
+ const chunks: string[] = [];
31
+ vi.spyOn(process.stderr, "write").mockImplementation((c: unknown) => {
32
+ chunks.push(String(c));
33
+ return true;
34
+ });
35
+ try {
36
+ expect(
37
+ await main([
38
+ "--suite",
39
+ "backend",
40
+ "--store-s3",
41
+ "my-bucket/prefix",
42
+ "--sha",
43
+ "abc",
44
+ "--branch",
45
+ "main",
46
+ "--artifacts",
47
+ "/tmp/__nonexistent_dir__",
48
+ ]),
49
+ ).toBe(2);
50
+ const stderr = chunks.join("");
51
+ expect(stderr).toContain("no lcov.info files found");
52
+ expect(stderr).not.toContain("unknown flag");
53
+ } finally {
54
+ vi.restoreAllMocks();
55
+ }
56
+ });
57
+
58
+ it("returns 2 when both --store-fs and --store-s3 are provided", async () => {
59
+ expect(
60
+ await main([
61
+ "--suite",
62
+ "backend",
63
+ "--store-fs",
64
+ "/tmp/s",
65
+ "--store-s3",
66
+ "bucket",
67
+ "--sha",
68
+ "abc",
69
+ "--branch",
70
+ "main",
71
+ ]),
72
+ ).toBe(2);
15
73
  });
16
74
 
17
75
  it("returns 2 on unknown flag", async () => {
18
- expect(await main(["--suite", "backend", "--store", "/tmp/s", "--unknown"])).toBe(2);
76
+ expect(
77
+ await main([
78
+ "--suite",
79
+ "backend",
80
+ "--store",
81
+ "/tmp/s",
82
+ "--sha",
83
+ "abc",
84
+ "--branch",
85
+ "main",
86
+ "--unknown",
87
+ ]),
88
+ ).toBe(2);
19
89
  });
20
90
 
21
91
  it("returns 2 when a flag is missing its value", async () => {
@@ -27,6 +97,7 @@ describe("runStorePut", () => {
27
97
  let tmpDir: string;
28
98
  let artifactsDir: string;
29
99
  let storeDir: string;
100
+ let store: FileSystemSuiteStore;
30
101
 
31
102
  beforeEach(() => {
32
103
  tmpDir = mkdtempSync(join(tmpdir(), "store-put-test-"));
@@ -34,6 +105,7 @@ describe("runStorePut", () => {
34
105
  storeDir = join(tmpDir, "store");
35
106
  mkdirSync(artifactsDir);
36
107
  mkdirSync(storeDir);
108
+ store = new FileSystemSuiteStore(storeDir);
37
109
  });
38
110
 
39
111
  afterEach(() => {
@@ -44,11 +116,11 @@ describe("runStorePut", () => {
44
116
  expect(
45
117
  await runStorePut({
46
118
  suite: "backend",
47
- store: storeDir,
119
+ store,
48
120
  artifacts: artifactsDir,
49
121
  stripPrefixes: [],
50
- sha: null,
51
- ref: null,
122
+ sha: "abc123",
123
+ branch: "main",
52
124
  }),
53
125
  ).toBe(2);
54
126
  });
@@ -62,19 +134,18 @@ describe("runStorePut", () => {
62
134
  expect(
63
135
  await runStorePut({
64
136
  suite: "backend",
65
- store: storeDir,
137
+ store,
66
138
  artifacts: artifactsDir,
67
139
  stripPrefixes: [],
68
140
  sha: "abc123",
69
- ref: "refs/heads/main",
141
+ branch: "main",
70
142
  }),
71
143
  ).toBe(0);
72
144
 
73
- const store = new FileSystemSuiteStore(storeDir);
74
145
  const suites = await store.list();
75
146
  expect(suites).toContain("backend");
76
147
 
77
- const buf = await store.get("backend");
148
+ const buf = await store.get("backend", { branch: "main" });
78
149
  expect(buf).not.toBeNull();
79
150
  const lcovText = buf!.toString();
80
151
  expect(lcovText).toContain("SF:backend/foo.mts");
@@ -90,22 +161,21 @@ describe("runStorePut", () => {
90
161
  expect(
91
162
  await runStorePut({
92
163
  suite: "backend",
93
- store: storeDir,
164
+ store,
94
165
  artifacts: artifactsDir,
95
166
  stripPrefixes: [],
96
- sha: null,
97
- ref: null,
167
+ sha: "abc123",
168
+ branch: "main",
98
169
  }),
99
170
  ).toBe(0);
100
171
 
101
- const store = new FileSystemSuiteStore(storeDir);
102
- const buf = await store.get("backend");
172
+ const buf = await store.get("backend", { branch: "main" });
103
173
  const lcovText = buf!.toString();
104
174
  expect(lcovText).toContain("SF:backend/a.mts");
105
175
  expect(lcovText).toContain("SF:backend/b.mts");
106
176
  });
107
177
 
108
- it("accepts --strip-prefix flag", async () => {
178
+ it("accepts --strip-prefix flag via main()", async () => {
109
179
  writeFileSync(
110
180
  join(artifactsDir, "lcov.info"),
111
181
  "SF:/home/runner/work/repo/backend/foo.mts\nDA:1,1\nend_of_record\n",
@@ -121,15 +191,39 @@ describe("runStorePut", () => {
121
191
  artifactsDir,
122
192
  "--strip-prefix",
123
193
  "/home/runner/work/repo",
194
+ "--sha",
195
+ "abc123",
196
+ "--branch",
197
+ "main",
124
198
  ]),
125
199
  ).toBe(0);
126
200
 
127
- const store = new FileSystemSuiteStore(storeDir);
128
- const buf = await store.get("backend");
129
- // After stripping the prefix, the stored lcov should have the normalized path
201
+ const buf = await store.get("backend", { branch: "main" });
130
202
  expect(buf!.toString()).toContain("SF:backend/foo.mts");
131
203
  });
132
204
 
205
+ it("--store-fs is an alias for --store", async () => {
206
+ writeFileSync(join(artifactsDir, "lcov.info"), "SF:web/app.tsx\nDA:10,1\nend_of_record\n");
207
+
208
+ expect(
209
+ await main([
210
+ "--suite",
211
+ "frontend",
212
+ "--store-fs",
213
+ storeDir,
214
+ "--artifacts",
215
+ artifactsDir,
216
+ "--sha",
217
+ "deadbeef",
218
+ "--branch",
219
+ "main",
220
+ ]),
221
+ ).toBe(0);
222
+
223
+ const stored = readFileSync(join(storeDir, "frontend", "sha", "deadbeef", "lcov.info"), "utf8");
224
+ expect(stored).toContain("SF:web/app.tsx");
225
+ });
226
+
133
227
  it("round-trips through main() CLI interface", async () => {
134
228
  writeFileSync(join(artifactsDir, "lcov.info"), "SF:web/app.tsx\nDA:10,1\nend_of_record\n");
135
229
 
@@ -143,12 +237,12 @@ describe("runStorePut", () => {
143
237
  artifactsDir,
144
238
  "--sha",
145
239
  "deadbeef",
146
- "--ref",
147
- "refs/heads/feat",
240
+ "--branch",
241
+ "main",
148
242
  ]),
149
243
  ).toBe(0);
150
244
 
151
- const stored = readFileSync(join(storeDir, "frontend", "lcov.info"), "utf8");
245
+ const stored = readFileSync(join(storeDir, "frontend", "sha", "deadbeef", "lcov.info"), "utf8");
152
246
  expect(stored).toContain("SF:web/app.tsx");
153
247
  });
154
248
  });
@@ -1,10 +1,12 @@
1
1
  export { runCheck } from "./commands/check.mts";
2
2
  export { runStorePut } from "./commands/store-put.mts";
3
3
  export { FileSystemSuiteStore } from "./suite-store.mts";
4
+ export { S3SuiteStore } from "./s3-suite-store.mts";
4
5
 
5
6
  export type { CheckArgs } from "./commands/check.mts";
6
7
  export type { StorePutArgs } from "./commands/store-put.mts";
7
8
  export type { SuiteStore, SuiteMeta } from "./suite-store.mts";
9
+ export type { S3SuiteStoreOptions } from "./s3-suite-store.mts";
8
10
  export type {
9
11
  CoverageCheckResult,
10
12
  BucketResult,
@@ -45,8 +45,8 @@ async function findExistingComment(repo: string, pr: number, gh: GhRunner): Prom
45
45
  * Posts or updates the sticky coverage-check comment on a pull request.
46
46
  *
47
47
  * - On failure: upserts the failure comment body (POST if absent, PATCH if exists).
48
- * - On pass: if a marker comment exists (from any prior run), replaces it with a
49
- * pass body. If no marker comment exists, stays silent.
48
+ * - On pass with prior comment: deletes the prior comment.
49
+ * - On pass with no prior comment: stays silent.
50
50
  */
51
51
  export async function upsertComment(
52
52
  body: string,
@@ -57,7 +57,12 @@ export async function upsertComment(
57
57
  ): Promise<void> {
58
58
  const existingId = await findExistingComment(repo, pr, gh);
59
59
 
60
- if (passed && existingId === null) return; // nothing to update
60
+ if (passed && existingId === null) return;
61
+
62
+ if (passed && existingId !== null) {
63
+ await gh(["api", `repos/${repo}/issues/comments/${existingId}`, "-X", "DELETE"]);
64
+ return;
65
+ }
61
66
 
62
67
  if (existingId !== null) {
63
68
  await gh([
@@ -14,7 +14,6 @@ function makeGh(responses: Record<string, string>): GhRunner & ReturnType<typeof
14
14
  }
15
15
 
16
16
  const FAIL_BODY = `${COMMENT_MARKER}\n## failed`;
17
- const PASS_BODY = `${COMMENT_MARKER}\n## passed`;
18
17
 
19
18
  describe("upsertComment", () => {
20
19
  it("posts a new comment on failure when none exists", async () => {
@@ -31,16 +30,16 @@ describe("upsertComment", () => {
31
30
  expect(calls.some((c) => c.includes("comments/99") && c.includes("PATCH"))).toBe(true);
32
31
  });
33
32
 
34
- it("patches an existing failure comment to pass body", async () => {
33
+ it("deletes an existing comment on pass", async () => {
35
34
  const gh = makeGh({ "issues/42/comments --paginate": "99\n" });
36
- await upsertComment(PASS_BODY, "owner/repo", 42, true, gh);
35
+ await upsertComment("", "owner/repo", 42, true, gh);
37
36
  const calls = gh.mock.calls.map((c) => c[0].join(" "));
38
- expect(calls.some((c) => c.includes("comments/99") && c.includes("PATCH"))).toBe(true);
37
+ expect(calls.some((c) => c.includes("comments/99") && c.includes("DELETE"))).toBe(true);
39
38
  });
40
39
 
41
40
  it("does nothing on pass when no prior comment exists", async () => {
42
41
  const gh = makeGh({ "issues/42/comments --paginate": "" });
43
- await upsertComment(PASS_BODY, "owner/repo", 42, true, gh);
42
+ await upsertComment("", "owner/repo", 42, true, gh);
44
43
  expect(gh.mock.calls.length).toBe(1);
45
44
  });
46
45
 
package/src/report.mts CHANGED
@@ -76,12 +76,3 @@ ${informationalSection}
76
76
 
77
77
  _Last updated: ${now} · [Workflow run](${runUrl})_`;
78
78
  }
79
-
80
- export function renderPassComment(runUrl: string, now: string = new Date().toISOString()): string {
81
- return `${COMMENT_MARKER}
82
- ## Patch coverage gate passed
83
-
84
- All workspace patch-coverage rules met.
85
-
86
- _Last updated: ${now} · [Workflow run](${runUrl})_`;
87
- }
@@ -1,10 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import {
3
- collapseRanges,
4
- renderFailureComment,
5
- renderPassComment,
6
- COMMENT_MARKER,
7
- } from "./report.mts";
2
+ import { collapseRanges, renderFailureComment, COMMENT_MARKER } from "./report.mts";
8
3
  import type { CoverageCheckResult } from "./types.mts";
9
4
 
10
5
  describe("collapseRanges", () => {
@@ -145,15 +140,3 @@ describe("renderFailureComment", () => {
145
140
  expect(comment).toContain("_No line-level data available_");
146
141
  });
147
142
  });
148
-
149
- describe("renderPassComment", () => {
150
- it("includes the marker", () => {
151
- const comment = renderPassComment("https://example.com/run/1", "2026-01-01T00:00:00.000Z");
152
- expect(comment.startsWith(COMMENT_MARKER)).toBe(true);
153
- });
154
-
155
- it("says passed", () => {
156
- const comment = renderPassComment("https://example.com/run/1", "2026-01-01T00:00:00.000Z");
157
- expect(comment).toContain("passed");
158
- });
159
- });
@@ -0,0 +1,138 @@
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
+ }