coverage-check 0.1.1 → 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.
@@ -30,6 +30,38 @@ describe("main argument validation", () => {
30
30
  it("returns exit code 2 when a flag is missing its value", async () => {
31
31
  expect(await main(["--rules"])).toBe(2);
32
32
  });
33
+
34
+ it("returns exit code 2 when a flag token follows as the value (e.g. --rules --pr)", async () => {
35
+ expect(await main(["--rules", "--pr"])).toBe(2);
36
+ });
37
+
38
+ it("returns exit code 2 when --pr is set but repo is empty", async () => {
39
+ const saved = process.env["GITHUB_REPOSITORY"];
40
+ delete process.env["GITHUB_REPOSITORY"];
41
+ try {
42
+ expect(await main(["--pr", "42"])).toBe(2);
43
+ } finally {
44
+ if (saved !== undefined) process.env["GITHUB_REPOSITORY"] = saved;
45
+ else delete process.env["GITHUB_REPOSITORY"];
46
+ }
47
+ });
48
+
49
+ it("uses fallback defaults when GITHUB_REPOSITORY/REF_NAME/STEP_SUMMARY are unset", async () => {
50
+ const saved: Record<string, string | undefined> = {};
51
+ for (const key of ["GITHUB_REPOSITORY", "GITHUB_REF_NAME", "GITHUB_STEP_SUMMARY"]) {
52
+ saved[key] = process.env[key];
53
+ delete process.env[key];
54
+ }
55
+ try {
56
+ // Any call exercises the default-init lines; unknown flag triggers parse error
57
+ expect(await main(["--unknown-flag"])).toBe(2);
58
+ } finally {
59
+ for (const [k, v] of Object.entries(saved)) {
60
+ if (v !== undefined) process.env[k] = v;
61
+ else delete process.env[k];
62
+ }
63
+ }
64
+ });
33
65
  });
34
66
 
35
67
  describe("main integration", () => {
@@ -96,6 +128,47 @@ describe("main integration", () => {
96
128
  ).toBe(2);
97
129
  });
98
130
 
131
+ it("accepts --branch flag", async () => {
132
+ expect(
133
+ await main([
134
+ "--rules",
135
+ join(tmpDir, "nonexistent.yml"),
136
+ "--branch",
137
+ "main",
138
+ "--artifacts",
139
+ artifactsDir,
140
+ ]),
141
+ ).toBe(2);
142
+ });
143
+
144
+ it("returns 2 when both --store-fs and --store-s3 are provided", async () => {
145
+ expect(
146
+ await main([
147
+ "--rules",
148
+ join(tmpDir, "nonexistent.yml"),
149
+ "--artifacts",
150
+ artifactsDir,
151
+ "--store-fs",
152
+ "/tmp/store",
153
+ "--store-s3",
154
+ "my-bucket",
155
+ ]),
156
+ ).toBe(2);
157
+ });
158
+
159
+ it("accepts --store-s3 flag (parse succeeds, fails on missing rules)", async () => {
160
+ expect(
161
+ await main([
162
+ "--rules",
163
+ join(tmpDir, "nonexistent.yml"),
164
+ "--artifacts",
165
+ artifactsDir,
166
+ "--store-s3",
167
+ "my-bucket/prefix",
168
+ ]),
169
+ ).toBe(2);
170
+ });
171
+
99
172
  it("returns 0 when no coverage data found — skips git entirely", async () => {
100
173
  expect(await main(["--rules", rulesPath, "--artifacts", artifactsDir])).toBe(0);
101
174
  });
@@ -160,7 +233,6 @@ describe("runCheck with suite store", () => {
160
233
  writeFileSync(rulesPath, "rules:\n - paths: backend/**\n patch_coverage_min: 90\n");
161
234
  store = new FileSystemSuiteStore(storeDir);
162
235
 
163
- // Set up a minimal git repo so HEAD is valid for diff tests
164
236
  const repoDir = join(tmpDir, "repo");
165
237
  mkdirSync(join(repoDir, "backend"), { recursive: true });
166
238
 
@@ -219,7 +291,10 @@ describe("runCheck with suite store", () => {
219
291
  });
220
292
 
221
293
  it("returns 0 when store has suites but diff is empty (base=head)", async () => {
222
- await store.put("frontend", Buffer.from("SF:web/app.tsx\nDA:1,1\nend_of_record\n"));
294
+ await store.put("frontend", Buffer.from("SF:web/app.tsx\nDA:1,1\nend_of_record\n"), {
295
+ sha: "test-sha",
296
+ branch: "main",
297
+ });
223
298
  expect(
224
299
  await runCheck({
225
300
  rules: rulesPath,
@@ -237,15 +312,18 @@ describe("runCheck with suite store", () => {
237
312
  });
238
313
 
239
314
  it("excludes the current suite from the store during check", async () => {
240
- // Store has "backend" suite with coverage; current artifacts is empty
241
- // With --suite=backend, the stored backend coverage should be excluded
242
- await store.put("backend", Buffer.from("SF:backend/foo.mts\nDA:1,1\nend_of_record\n"));
243
- await store.put("frontend", Buffer.from("SF:web/app.tsx\nDA:1,1\nend_of_record\n"));
244
-
315
+ await store.put("backend", Buffer.from("SF:backend/foo.mts\nDA:1,1\nend_of_record\n"), {
316
+ sha: "test-sha",
317
+ branch: "main",
318
+ });
319
+ await store.put("frontend", Buffer.from("SF:web/app.tsx\nDA:1,1\nend_of_record\n"), {
320
+ sha: "test-sha",
321
+ branch: "main",
322
+ });
245
323
  expect(
246
324
  await runCheck({
247
325
  rules: rulesPath,
248
- artifacts: artifactsDir, // empty
326
+ artifacts: artifactsDir,
249
327
  base: "HEAD",
250
328
  head: "HEAD",
251
329
  pr: null,
@@ -253,16 +331,17 @@ describe("runCheck with suite store", () => {
253
331
  json: null,
254
332
  stripPrefixes: [],
255
333
  store,
256
- suite: "backend", // excludes the stored backend suite
334
+ suite: "backend",
257
335
  }),
258
336
  ).toBe(0);
259
337
  });
260
338
 
261
339
  it("includes non-current suites from the store", async () => {
262
- // Put a "frontend" suite in the store; artifacts has backend coverage
263
- await store.put("frontend", Buffer.from("SF:web/app.tsx\nDA:1,1\nend_of_record\n"));
340
+ await store.put("frontend", Buffer.from("SF:web/app.tsx\nDA:1,1\nend_of_record\n"), {
341
+ sha: "test-sha",
342
+ branch: "main",
343
+ });
264
344
  writeFileSync(join(artifactsDir, "lcov.info"), "SF:backend/foo.mts\nDA:1,1\nend_of_record\n");
265
- // With suite=backend, frontend from store should still be merged
266
345
  expect(
267
346
  await runCheck({
268
347
  rules: rulesPath,
@@ -280,17 +359,19 @@ describe("runCheck with suite store", () => {
280
359
  });
281
360
 
282
361
  it("handles a store that returns null from get() gracefully", async () => {
283
- // Custom store: list returns a suite, but get returns null (e.g. file was deleted)
284
362
  const nullStore = {
285
363
  async list() {
286
364
  return ["backend"];
287
365
  },
288
- async get(_suite: string) {
366
+ async get(_suite: string, _opts?: { sha?: string; branch?: string }) {
289
367
  return null;
290
368
  },
291
- async put() {},
369
+ async put(
370
+ _suite: string,
371
+ _lcov: Buffer,
372
+ _meta: { sha: string; branch: string },
373
+ ): Promise<void> {},
292
374
  };
293
- // No local artifacts + store returns null → no reports → return 0
294
375
  expect(
295
376
  await runCheck({
296
377
  rules: rulesPath,
@@ -308,7 +389,10 @@ describe("runCheck with suite store", () => {
308
389
  });
309
390
 
310
391
  it("constructs a real runUrl when GITHUB_SERVER_URL and GITHUB_RUN_ID are set", async () => {
311
- await store.put("frontend", Buffer.from("SF:web/app.tsx\nDA:1,1\nend_of_record\n"));
392
+ await store.put("frontend", Buffer.from("SF:web/app.tsx\nDA:1,1\nend_of_record\n"), {
393
+ sha: "test-sha",
394
+ branch: "main",
395
+ });
312
396
 
313
397
  const calls: string[][] = [];
314
398
  const gh = async (args: string[]) => {
@@ -322,7 +406,6 @@ describe("runCheck with suite store", () => {
322
406
  process.env["GITHUB_RUN_ID"] = "12345";
323
407
 
324
408
  try {
325
- // With frontend from store and empty diff (HEAD=HEAD), passes
326
409
  await runCheck({
327
410
  rules: rulesPath,
328
411
  artifacts: artifactsDir,
@@ -336,7 +419,6 @@ describe("runCheck with suite store", () => {
336
419
  suite: null,
337
420
  gh,
338
421
  });
339
- // gh was called (lookup for existing comment)
340
422
  expect(calls.length).toBeGreaterThanOrEqual(1);
341
423
  } finally {
342
424
  if (origServer === undefined) delete process.env["GITHUB_SERVER_URL"];
@@ -347,7 +429,6 @@ describe("runCheck with suite store", () => {
347
429
  });
348
430
 
349
431
  it("accepts --store and --suite flags via main()", async () => {
350
- // --store and --suite valid flags, no lcov → returns 0
351
432
  expect(
352
433
  await main([
353
434
  "--rules",
@@ -361,6 +442,21 @@ describe("runCheck with suite store", () => {
361
442
  ]),
362
443
  ).toBe(0);
363
444
  });
445
+
446
+ it("accepts --store-fs flag as alias for --store", async () => {
447
+ expect(
448
+ await main([
449
+ "--rules",
450
+ rulesPath,
451
+ "--artifacts",
452
+ artifactsDir,
453
+ "--store-fs",
454
+ storeDir,
455
+ "--suite",
456
+ "backend",
457
+ ]),
458
+ ).toBe(0);
459
+ });
364
460
  });
365
461
 
366
462
  describe("with a real git repo and a known diff", () => {
@@ -527,7 +623,6 @@ describe("with a real git repo and a known diff", () => {
527
623
  gh,
528
624
  });
529
625
  expect(result).toBe(1);
530
- // gh was called: first to look up existing comment, then to post
531
626
  expect(calls.length).toBeGreaterThanOrEqual(1);
532
627
  });
533
628
 
@@ -580,7 +675,6 @@ describe("with a real git repo and a known diff", () => {
580
675
  suite: null,
581
676
  gh,
582
677
  });
583
- // Still returns 1 even though gh call failed
584
678
  expect(result).toBe(1);
585
679
  });
586
680
 
@@ -615,10 +709,11 @@ describe("with a real git repo and a known diff", () => {
615
709
  mkdirSync(storeDir);
616
710
  const store = new FileSystemSuiteStore(storeDir);
617
711
 
618
- // Store has frontend coverage (unrelated to the diff)
619
- await store.put("frontend", Buffer.from("SF:web/app.tsx\nDA:1,1\nend_of_record\n"));
712
+ await store.put("frontend", Buffer.from("SF:web/app.tsx\nDA:1,1\nend_of_record\n"), {
713
+ sha: "test-sha",
714
+ branch: "main",
715
+ });
620
716
 
621
- // Current artifacts has backend coverage (line 2 now covered)
622
717
  writeFileSync(
623
718
  join(artifactsDir, "lcov.info"),
624
719
  "SF:backend/foo.mts\nDA:1,1\nDA:2,1\nend_of_record\n",
@@ -639,4 +734,137 @@ describe("with a real git repo and a known diff", () => {
639
734
  }),
640
735
  ).toBe(0);
641
736
  });
737
+
738
+ it("writes step summary when summaryFile is provided", async () => {
739
+ const summaryFile = join(tmpDir, "summary.md");
740
+ writeFileSync(summaryFile, "");
741
+ writeFileSync(
742
+ join(artifactsDir, "lcov.info"),
743
+ "SF:backend/foo.mts\nDA:1,1\nDA:2,1\nend_of_record\n",
744
+ );
745
+ await runCheck({
746
+ rules: rulesPath,
747
+ artifacts: artifactsDir,
748
+ base: baseSha,
749
+ head: headSha,
750
+ pr: null,
751
+ repo: "",
752
+ json: null,
753
+ stripPrefixes: [],
754
+ store: null,
755
+ suite: "backend",
756
+ summaryFile,
757
+ });
758
+ const content = readFileSync(summaryFile, "utf8");
759
+ expect(content).toContain("Coverage summary");
760
+ });
761
+
762
+ it("does not write step summary when summaryFile is null even if env var is set", async () => {
763
+ const summaryFile = join(tmpDir, "should-not-exist.md");
764
+ writeFileSync(
765
+ join(artifactsDir, "lcov.info"),
766
+ "SF:backend/foo.mts\nDA:1,1\nDA:2,1\nend_of_record\n",
767
+ );
768
+ const origEnv = process.env["GITHUB_STEP_SUMMARY"];
769
+ process.env["GITHUB_STEP_SUMMARY"] = summaryFile;
770
+ try {
771
+ await runCheck({
772
+ rules: rulesPath,
773
+ artifacts: artifactsDir,
774
+ base: baseSha,
775
+ head: headSha,
776
+ pr: null,
777
+ repo: "",
778
+ json: null,
779
+ stripPrefixes: [],
780
+ store: null,
781
+ suite: "backend",
782
+ summaryFile: null,
783
+ });
784
+ } finally {
785
+ if (origEnv === undefined) delete process.env["GITHUB_STEP_SUMMARY"];
786
+ else process.env["GITHUB_STEP_SUMMARY"] = origEnv;
787
+ }
788
+ expect(() => readFileSync(summaryFile, "utf8")).toThrow();
789
+ });
790
+
791
+ it("uses N/A runUrl when GITHUB_SERVER_URL and GITHUB_RUN_ID are unset", async () => {
792
+ writeFileSync(
793
+ join(artifactsDir, "lcov.info"),
794
+ "SF:backend/foo.mts\nDA:1,1\nDA:2,1\nend_of_record\n",
795
+ );
796
+ const savedServer = process.env["GITHUB_SERVER_URL"];
797
+ const savedRunId = process.env["GITHUB_RUN_ID"];
798
+ delete process.env["GITHUB_SERVER_URL"];
799
+ delete process.env["GITHUB_RUN_ID"];
800
+ try {
801
+ await runCheck({
802
+ rules: rulesPath,
803
+ artifacts: artifactsDir,
804
+ base: baseSha,
805
+ head: headSha,
806
+ pr: null,
807
+ repo: "",
808
+ json: null,
809
+ stripPrefixes: [],
810
+ store: null,
811
+ suite: "backend",
812
+ });
813
+ } finally {
814
+ if (savedServer !== undefined) process.env["GITHUB_SERVER_URL"] = savedServer;
815
+ else delete process.env["GITHUB_SERVER_URL"];
816
+ if (savedRunId !== undefined) process.env["GITHUB_RUN_ID"] = savedRunId;
817
+ else delete process.env["GITHUB_RUN_ID"];
818
+ }
819
+ });
820
+
821
+ it("returns 2 when writeSummary throws (unwritable summaryFile path)", async () => {
822
+ writeFileSync(
823
+ join(artifactsDir, "lcov.info"),
824
+ "SF:backend/foo.mts\nDA:1,1\nDA:2,1\nend_of_record\n",
825
+ );
826
+ // Pass the tmp directory itself as summaryFile — appendFileSync on a dir throws EISDIR
827
+ expect(
828
+ await runCheck({
829
+ rules: rulesPath,
830
+ artifacts: artifactsDir,
831
+ base: baseSha,
832
+ head: headSha,
833
+ pr: null,
834
+ repo: "",
835
+ json: null,
836
+ stripPrefixes: [],
837
+ store: null,
838
+ suite: "backend",
839
+ summaryFile: tmpDir,
840
+ }),
841
+ ).toBe(2);
842
+ });
843
+
844
+ it("does not write summary when summaryFile is undefined and GITHUB_STEP_SUMMARY is unset", async () => {
845
+ writeFileSync(
846
+ join(artifactsDir, "lcov.info"),
847
+ "SF:backend/foo.mts\nDA:1,1\nDA:2,1\nend_of_record\n",
848
+ );
849
+ const savedSummary = process.env["GITHUB_STEP_SUMMARY"];
850
+ delete process.env["GITHUB_STEP_SUMMARY"];
851
+ try {
852
+ await runCheck({
853
+ rules: rulesPath,
854
+ artifacts: artifactsDir,
855
+ base: baseSha,
856
+ head: headSha,
857
+ pr: null,
858
+ repo: "",
859
+ json: null,
860
+ stripPrefixes: [],
861
+ store: null,
862
+ suite: "backend",
863
+ summaryFile: undefined,
864
+ });
865
+ } finally {
866
+ if (savedSummary !== undefined) process.env["GITHUB_STEP_SUMMARY"] = savedSummary;
867
+ else delete process.env["GITHUB_STEP_SUMMARY"];
868
+ }
869
+ });
642
870
  });
@@ -2,35 +2,40 @@ import { readFileSync } from "node:fs";
2
2
  import { parseLcov } from "../lcov-parser.mts";
3
3
  import { mergeLcov, toLcov } from "../lcov-merge.mts";
4
4
  import { collectLcovFiles, buildStripPrefixes } from "../load-artifacts.mts";
5
- import { FileSystemSuiteStore } from "../suite-store.mts";
5
+ import { makeStore } from "../store-factory.mts";
6
+ import { assertSafePathComponent } from "../suite-store.mts";
7
+ import type { SuiteStore } from "../suite-store.mts";
6
8
 
7
9
  const stdout = (msg: string) => process.stdout.write(`${msg}\n`);
8
10
  const stderr = (msg: string) => process.stderr.write(`${msg}\n`);
9
11
 
10
12
  export type StorePutArgs = {
11
13
  suite: string;
12
- store: string;
14
+ store: SuiteStore;
13
15
  artifacts: string;
14
16
  stripPrefixes: string[];
15
- sha: string | null;
16
- ref: string | null;
17
+ sha: string;
18
+ branch: string;
17
19
  };
18
20
 
19
21
  function parseArgs(argv: string[]): StorePutArgs {
20
- const args: StorePutArgs = {
22
+ let storeFs: string | null = null;
23
+ let storeS3: string | null = null;
24
+ const args = {
21
25
  suite: "",
22
- store: "",
23
26
  artifacts: "./coverage-artifacts",
24
- stripPrefixes: [],
25
- sha: null,
26
- ref: null,
27
+ stripPrefixes: [] as string[],
28
+ sha: "",
29
+ branch: "",
27
30
  };
28
31
 
29
32
  for (let i = 0; i < argv.length; i++) {
30
33
  const flag = argv[i]!;
31
34
  const next = argv[i + 1];
32
35
  const val = (): string => {
33
- if (next === undefined) throw new Error(`${flag} requires a value`);
36
+ if (next === undefined || next.startsWith("--")) {
37
+ throw new Error(`${flag} requires a value`);
38
+ }
34
39
  i++;
35
40
  return next;
36
41
  };
@@ -39,7 +44,11 @@ function parseArgs(argv: string[]): StorePutArgs {
39
44
  args.suite = val();
40
45
  break;
41
46
  case "--store":
42
- args.store = val();
47
+ case "--store-fs":
48
+ storeFs = val();
49
+ break;
50
+ case "--store-s3":
51
+ storeS3 = val();
43
52
  break;
44
53
  case "--artifacts":
45
54
  args.artifacts = val();
@@ -50,8 +59,8 @@ function parseArgs(argv: string[]): StorePutArgs {
50
59
  case "--sha":
51
60
  args.sha = val();
52
61
  break;
53
- case "--ref":
54
- args.ref = val();
62
+ case "--branch":
63
+ args.branch = val();
55
64
  break;
56
65
  default:
57
66
  throw new Error(`unknown flag: ${flag}`);
@@ -59,8 +68,16 @@ function parseArgs(argv: string[]): StorePutArgs {
59
68
  }
60
69
 
61
70
  if (!args.suite) throw new Error("--suite is required");
62
- if (!args.store) throw new Error("--store is required");
63
- return args;
71
+ if (storeFs && storeS3) throw new Error("--store-fs and --store-s3 are mutually exclusive");
72
+ if (!storeFs && !storeS3) throw new Error("--store-fs/--store or --store-s3 is required");
73
+ if (!args.sha) throw new Error("--sha is required");
74
+ if (!args.branch) throw new Error("--branch is required");
75
+ assertSafePathComponent(args.suite, "suite");
76
+ assertSafePathComponent(args.sha, "sha");
77
+ assertSafePathComponent(args.branch, "branch");
78
+
79
+ const store = makeStore({ fs: storeFs, s3: storeS3 })!;
80
+ return { ...args, store };
64
81
  }
65
82
 
66
83
  export async function main(argv: string[]): Promise<number> {
@@ -68,8 +85,7 @@ export async function main(argv: string[]): Promise<number> {
68
85
  try {
69
86
  args = parseArgs(argv);
70
87
  } catch (err) {
71
- /* c8 ignore next */
72
- stderr(`coverage-check store-put: ${err instanceof Error ? err.message : err}`);
88
+ stderr(`coverage-check store-put: ${String(err)}`);
73
89
  return 2;
74
90
  }
75
91
  return runStorePut(args);
@@ -87,14 +103,13 @@ export async function runStorePut(args: StorePutArgs): Promise<number> {
87
103
  const merged = mergeLcov(reports);
88
104
  const lcovText = toLcov(merged);
89
105
 
90
- const store = new FileSystemSuiteStore(args.store);
91
- await store.put(args.suite, Buffer.from(lcovText, "utf8"), {
92
- sha: args.sha ?? undefined,
93
- ref: args.ref ?? undefined,
106
+ await args.store.put(args.suite, Buffer.from(lcovText, "utf8"), {
107
+ sha: args.sha,
108
+ branch: args.branch,
94
109
  });
95
110
 
96
111
  stdout(
97
- `coverage-check store-put: stored suite "${args.suite}" (${lcovFiles.length} file(s)) ${args.store}`,
112
+ `coverage-check store-put: stored suite "${args.suite}" (${lcovFiles.length} file(s)) sha=${args.sha} branch=${args.branch}`,
98
113
  );
99
114
  return 0;
100
115
  }