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.
@@ -0,0 +1,308 @@
1
+ import { Readable } from "node:stream";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { GetObjectCommand, ListObjectsV2Command, PutObjectCommand } from "@aws-sdk/client-s3";
4
+ import { S3SuiteStore } from "./s3-suite-store.mts";
5
+
6
+ function makeClient(sendImpl: (cmd: unknown) => Promise<unknown>) {
7
+ return { send: vi.fn(sendImpl) };
8
+ }
9
+
10
+ const BUCKET = "test-bucket";
11
+ const PREFIX = "coverage";
12
+ const LCOV = "SF:backend/foo.mts\nDA:1,1\nend_of_record\n";
13
+ const POINTER = JSON.stringify({ sha: "abc123", timestamp: "2026-01-01T00:00:00.000Z" });
14
+
15
+ function notFound() {
16
+ const err = new Error("NoSuchKey");
17
+ err.name = "NoSuchKey";
18
+ return Promise.reject(err);
19
+ }
20
+
21
+ describe("S3SuiteStore — list()", () => {
22
+ it("returns suite names from CommonPrefixes", async () => {
23
+ const client = makeClient(async () => ({
24
+ CommonPrefixes: [{ Prefix: `${PREFIX}/backend/` }, { Prefix: `${PREFIX}/frontend/` }],
25
+ }));
26
+ const store = new S3SuiteStore({ bucket: BUCKET, prefix: PREFIX, client });
27
+ expect(await store.list()).toEqual(["backend", "frontend"]);
28
+ expect(client.send).toHaveBeenCalledOnce();
29
+ const cmd = client.send.mock.calls[0][0];
30
+ expect(cmd).toBeInstanceOf(ListObjectsV2Command);
31
+ });
32
+
33
+ it("returns empty array when CommonPrefixes is absent", async () => {
34
+ const client = makeClient(async () => ({}));
35
+ const store = new S3SuiteStore({ bucket: BUCKET, prefix: PREFIX, client });
36
+ expect(await store.list()).toEqual([]);
37
+ });
38
+
39
+ it("works without a prefix", async () => {
40
+ const client = makeClient(async () => ({
41
+ CommonPrefixes: [{ Prefix: "backend/" }],
42
+ }));
43
+ const store = new S3SuiteStore({ bucket: BUCKET, client });
44
+ expect(await store.list()).toEqual(["backend"]);
45
+ });
46
+
47
+ it("filters out empty-string entries", async () => {
48
+ const client = makeClient(async () => ({
49
+ CommonPrefixes: [{ Prefix: undefined }, { Prefix: `${PREFIX}/web/` }],
50
+ }));
51
+ const store = new S3SuiteStore({ bucket: BUCKET, prefix: PREFIX, client });
52
+ expect(await store.list()).toEqual(["web"]);
53
+ });
54
+
55
+ it("paginates through all results when IsTruncated is true", async () => {
56
+ let callCount = 0;
57
+ const client = makeClient(async () => {
58
+ callCount++;
59
+ if (callCount === 1) {
60
+ return {
61
+ CommonPrefixes: [{ Prefix: `${PREFIX}/suite-a/` }],
62
+ IsTruncated: true,
63
+ NextContinuationToken: "token-xyz",
64
+ };
65
+ }
66
+ return { CommonPrefixes: [{ Prefix: `${PREFIX}/suite-b/` }], IsTruncated: false };
67
+ });
68
+ const store = new S3SuiteStore({ bucket: BUCKET, prefix: PREFIX, client });
69
+ expect(await store.list()).toEqual(["suite-a", "suite-b"]);
70
+ expect(client.send).toHaveBeenCalledTimes(2);
71
+ const secondCmd = client.send.mock.calls[1][0] as ListObjectsV2Command;
72
+ expect(secondCmd.input.ContinuationToken).toBe("token-xyz");
73
+ });
74
+ });
75
+
76
+ describe("S3SuiteStore — get()", () => {
77
+ it("follows branch pointer to fetch lcov (Uint8Array body)", async () => {
78
+ let callCount = 0;
79
+ const client = makeClient(async (cmd) => {
80
+ callCount++;
81
+ if (cmd instanceof GetObjectCommand) {
82
+ if (callCount === 1) return { Body: Buffer.from(POINTER) };
83
+ return { Body: Buffer.from(LCOV) };
84
+ }
85
+ return {};
86
+ });
87
+ const store = new S3SuiteStore({ bucket: BUCKET, prefix: PREFIX, client });
88
+ const result = await store.get("backend", { branch: "main" });
89
+ expect(result!.toString()).toBe(LCOV);
90
+ expect(client.send).toHaveBeenCalledTimes(2);
91
+ });
92
+
93
+ it("defaults to main branch when no opts provided", async () => {
94
+ let callCount = 0;
95
+ const client = makeClient(async (cmd) => {
96
+ callCount++;
97
+ if (cmd instanceof GetObjectCommand) {
98
+ if (callCount === 1) return { Body: Buffer.from(POINTER) };
99
+ return { Body: Buffer.from(LCOV) };
100
+ }
101
+ return {};
102
+ });
103
+ const store = new S3SuiteStore({ bucket: BUCKET, prefix: PREFIX, client });
104
+ const result = await store.get("backend");
105
+ expect(result).not.toBeNull();
106
+ });
107
+
108
+ it("fetches by explicit sha without reading the pointer", async () => {
109
+ const client = makeClient(async (cmd) => {
110
+ if (cmd instanceof GetObjectCommand) return { Body: Buffer.from(LCOV) };
111
+ return {};
112
+ });
113
+ const store = new S3SuiteStore({ bucket: BUCKET, prefix: PREFIX, client });
114
+ const result = await store.get("backend", { sha: "abc123" });
115
+ expect(result!.toString()).toBe(LCOV);
116
+ expect(client.send).toHaveBeenCalledOnce();
117
+ });
118
+
119
+ it("returns null when branch pointer is not found", async () => {
120
+ const client = makeClient(async (cmd) => {
121
+ if (cmd instanceof GetObjectCommand) return notFound();
122
+ return {};
123
+ });
124
+ const store = new S3SuiteStore({ bucket: BUCKET, prefix: PREFIX, client });
125
+ expect(await store.get("backend", { branch: "main" })).toBeNull();
126
+ });
127
+
128
+ it("returns null when lcov object is not found", async () => {
129
+ let callCount = 0;
130
+ const client = makeClient(async (cmd) => {
131
+ callCount++;
132
+ if (cmd instanceof GetObjectCommand) {
133
+ if (callCount === 1) return { Body: Buffer.from(POINTER) };
134
+ const err = new Error("NotFound");
135
+ err.name = "NotFound";
136
+ return Promise.reject(err);
137
+ }
138
+ return {};
139
+ });
140
+ const store = new S3SuiteStore({ bucket: BUCKET, prefix: PREFIX, client });
141
+ expect(await store.get("backend", { branch: "main" })).toBeNull();
142
+ });
143
+
144
+ it("rethrows unexpected errors from pointer fetch", async () => {
145
+ const client = makeClient(async () => Promise.reject(new Error("network error")));
146
+ const store = new S3SuiteStore({ bucket: BUCKET, client });
147
+ await expect(store.get("backend")).rejects.toThrow("network error");
148
+ });
149
+
150
+ it("rethrows unexpected errors from lcov fetch", async () => {
151
+ let callCount = 0;
152
+ const client = makeClient(async (cmd) => {
153
+ callCount++;
154
+ if (cmd instanceof GetObjectCommand) {
155
+ if (callCount === 1) return { Body: Buffer.from(POINTER) };
156
+ return Promise.reject(new Error("read error"));
157
+ }
158
+ return {};
159
+ });
160
+ const store = new S3SuiteStore({ bucket: BUCKET, client });
161
+ await expect(store.get("backend")).rejects.toThrow("read error");
162
+ });
163
+
164
+ it("handles a Readable body", async () => {
165
+ let callCount = 0;
166
+ const client = makeClient(async (cmd) => {
167
+ callCount++;
168
+ if (cmd instanceof GetObjectCommand) {
169
+ if (callCount === 1) return { Body: Buffer.from(POINTER) };
170
+ return { Body: Readable.from([Buffer.from(LCOV)]) };
171
+ }
172
+ return {};
173
+ });
174
+ const store = new S3SuiteStore({ bucket: BUCKET, prefix: PREFIX, client });
175
+ const result = await store.get("backend");
176
+ expect(result!.toString()).toBe(LCOV);
177
+ });
178
+
179
+ it("handles a Blob body", async () => {
180
+ let callCount = 0;
181
+ const client = makeClient(async (cmd) => {
182
+ callCount++;
183
+ if (cmd instanceof GetObjectCommand) {
184
+ if (callCount === 1) return { Body: Buffer.from(POINTER) };
185
+ return { Body: new Blob([LCOV]) };
186
+ }
187
+ return {};
188
+ });
189
+ const store = new S3SuiteStore({ bucket: BUCKET, prefix: PREFIX, client });
190
+ const result = await store.get("backend");
191
+ expect(result!.toString()).toBe(LCOV);
192
+ });
193
+
194
+ it("throws for an unexpected body type", async () => {
195
+ let callCount = 0;
196
+ const client = makeClient(async (cmd) => {
197
+ callCount++;
198
+ if (cmd instanceof GetObjectCommand) {
199
+ if (callCount === 1) return { Body: Buffer.from(POINTER) };
200
+ return { Body: 12345 }; // not Readable, Uint8Array, or Blob
201
+ }
202
+ return {};
203
+ });
204
+ const store = new S3SuiteStore({ bucket: BUCKET, prefix: PREFIX, client });
205
+ await expect(store.get("backend")).rejects.toThrow("unexpected S3 response body type");
206
+ });
207
+ });
208
+
209
+ describe("S3SuiteStore — put()", () => {
210
+ it("sends two PutObjectCommands: lcov payload and branch pointer", async () => {
211
+ const client = makeClient(async () => ({}));
212
+ const store = new S3SuiteStore({ bucket: BUCKET, prefix: PREFIX, client });
213
+ await store.put("backend", Buffer.from(LCOV), { sha: "abc123", branch: "main" });
214
+
215
+ expect(client.send).toHaveBeenCalledTimes(2);
216
+ const cmds = client.send.mock.calls.map((c) => c[0]);
217
+ expect(cmds.every((c) => c instanceof PutObjectCommand)).toBe(true);
218
+
219
+ const keys = cmds.map((c) => (c as PutObjectCommand).input.Key);
220
+ expect(keys).toContain(`${PREFIX}/backend/sha/abc123/lcov.info`);
221
+ expect(keys).toContain(`${PREFIX}/backend/branch/main/latest.json`);
222
+ });
223
+
224
+ it("uses provided timestamp in pointer", async () => {
225
+ const client = makeClient(async () => ({}));
226
+ const store = new S3SuiteStore({ bucket: BUCKET, prefix: PREFIX, client });
227
+ const ts = "2026-05-01T00:00:00.000Z";
228
+ await store.put("backend", Buffer.from(LCOV), {
229
+ sha: "abc",
230
+ branch: "main",
231
+ timestamp: ts,
232
+ });
233
+
234
+ const pointerCmd = client.send.mock.calls
235
+ .map((c) => c[0] as PutObjectCommand)
236
+ .find((c) => c.input.Key?.endsWith("latest.json"))!;
237
+ const pointer = JSON.parse((pointerCmd.input.Body as Buffer).toString("utf8"));
238
+ expect(pointer.timestamp).toBe(ts);
239
+ });
240
+
241
+ it("generates a timestamp when none is provided", async () => {
242
+ const client = makeClient(async () => ({}));
243
+ const store = new S3SuiteStore({ bucket: BUCKET, prefix: PREFIX, client });
244
+ await store.put("backend", Buffer.from(LCOV), { sha: "abc", branch: "main" });
245
+
246
+ const pointerCmd = client.send.mock.calls
247
+ .map((c) => c[0] as PutObjectCommand)
248
+ .find((c) => (c as PutObjectCommand).input.Key?.endsWith("latest.json"))!;
249
+ const pointer = JSON.parse((pointerCmd.input.Body as Buffer).toString("utf8"));
250
+ expect(typeof pointer.timestamp).toBe("string");
251
+ expect(pointer.timestamp.length).toBeGreaterThan(0);
252
+ });
253
+
254
+ it("works without a prefix", async () => {
255
+ const client = makeClient(async () => ({}));
256
+ const store = new S3SuiteStore({ bucket: BUCKET, client });
257
+ await store.put("backend", Buffer.from(LCOV), { sha: "abc", branch: "main" });
258
+
259
+ const keys = client.send.mock.calls.map((c) => (c[0] as PutObjectCommand).input.Key);
260
+ expect(keys).toContain("backend/sha/abc/lcov.info");
261
+ expect(keys).toContain("backend/branch/main/latest.json");
262
+ });
263
+ });
264
+
265
+ describe("S3SuiteStore — path traversal protection", () => {
266
+ const store = new S3SuiteStore({
267
+ bucket: BUCKET,
268
+ prefix: PREFIX,
269
+ client: makeClient(async () => ({})),
270
+ });
271
+ const invalid = ["", ".", "..", "a/b", "a\\b"];
272
+ for (const val of invalid) {
273
+ it(`get() rejects suite=${JSON.stringify(val)}`, async () => {
274
+ await expect(store.get(val)).rejects.toThrow("invalid suite");
275
+ });
276
+ it(`get() rejects branch=${JSON.stringify(val)}`, async () => {
277
+ await expect(store.get("backend", { branch: val })).rejects.toThrow("invalid branch");
278
+ });
279
+ it(`get() rejects sha=${JSON.stringify(val)}`, async () => {
280
+ await expect(store.get("backend", { sha: val })).rejects.toThrow("invalid sha");
281
+ });
282
+ it(`put() rejects suite=${JSON.stringify(val)}`, async () => {
283
+ await expect(store.put(val, Buffer.from(""), { sha: "abc", branch: "main" })).rejects.toThrow(
284
+ "invalid suite",
285
+ );
286
+ });
287
+ it(`put() rejects sha=${JSON.stringify(val)}`, async () => {
288
+ await expect(
289
+ store.put("backend", Buffer.from(""), { sha: val, branch: "main" }),
290
+ ).rejects.toThrow("invalid sha");
291
+ });
292
+ it(`put() rejects branch=${JSON.stringify(val)}`, async () => {
293
+ await expect(
294
+ store.put("backend", Buffer.from(""), { sha: "abc", branch: val }),
295
+ ).rejects.toThrow("invalid branch");
296
+ });
297
+ }
298
+ });
299
+
300
+ describe("S3SuiteStore — constructor prefix normalization", () => {
301
+ it("strips all trailing slashes from prefix", async () => {
302
+ const client = makeClient(async () => ({ CommonPrefixes: [] }));
303
+ const store = new S3SuiteStore({ bucket: BUCKET, prefix: "coverage///", client });
304
+ expect(await store.list()).toEqual([]);
305
+ const cmd = client.send.mock.calls[0][0] as InstanceType<typeof ListObjectsV2Command>;
306
+ expect(cmd.input.Prefix).toBe("coverage/");
307
+ });
308
+ });
@@ -0,0 +1,89 @@
1
+ import { appendFileSync } from "node:fs";
2
+ import type { LcovData } from "./types.mts";
3
+ import type { CoverageCheckResult } from "./types.mts";
4
+
5
+ export type SuiteSource = {
6
+ suite: string;
7
+ source: "fresh" | "store";
8
+ lcov: LcovData;
9
+ };
10
+
11
+ function suiteTotals(lcov: LcovData): { hit: number; total: number } {
12
+ let hit = 0;
13
+ let total = 0;
14
+ for (const lines of lcov.values()) {
15
+ for (const count of lines.values()) {
16
+ total++;
17
+ if (count > 0) hit++;
18
+ }
19
+ }
20
+ return { hit, total };
21
+ }
22
+
23
+ function pctStr(hit: number, total: number): string {
24
+ if (total === 0) return "—";
25
+ return `${((hit / total) * 100).toFixed(1)}% (${hit}/${total})`;
26
+ }
27
+
28
+ function escMd(s: string): string {
29
+ return s.replace(/\|/g, "\\|");
30
+ }
31
+
32
+ export function buildSummaryMarkdown(
33
+ suiteSources: SuiteSource[],
34
+ result: CoverageCheckResult,
35
+ runUrl: string,
36
+ branch = "main",
37
+ ): string {
38
+ const suiteRows = suiteSources
39
+ .map(({ suite, source, lcov }) => {
40
+ const { hit, total } = suiteTotals(lcov);
41
+ const sourceLabel = source === "fresh" ? "fresh" : `store (${escMd(branch)})`;
42
+ return `| \`${escMd(suite)}\` | ${sourceLabel} | ${pctStr(hit, total)} |`;
43
+ })
44
+ .join("\n");
45
+
46
+ const suiteTable = [
47
+ "| Suite | Source | Line coverage |",
48
+ "|---|---|---|",
49
+ suiteRows || "| — | — | — |",
50
+ ].join("\n");
51
+
52
+ const ruleRows = result.buckets
53
+ .map((b) => {
54
+ const status = b.passed ? "✅" : "❌";
55
+ const pct = b.coverable > 0 ? `${((b.hit / b.coverable) * 100).toFixed(1)}%` : "—";
56
+ return `| \`${escMd(b.rule)}\` | ${b.threshold}% | ${pct} | ${status} |`;
57
+ })
58
+ .join("\n");
59
+
60
+ const ruleTable = [
61
+ "| Rule | Threshold | Patch coverage | Status |",
62
+ "|---|---|---|---|",
63
+ ruleRows || "| — | — | — | — |",
64
+ ].join("\n");
65
+
66
+ const overall = result.passed ? "✅ passed" : "❌ failed";
67
+ const runLink = runUrl !== "N/A" ? `\n\n_[View run](${runUrl})_` : "";
68
+
69
+ return `## Coverage summary — ${overall}
70
+
71
+ ### Suite totals
72
+
73
+ ${suiteTable}
74
+
75
+ ### Patch coverage
76
+
77
+ ${ruleTable}${runLink}
78
+ `;
79
+ }
80
+
81
+ export function writeSummary(
82
+ summaryFile: string,
83
+ suiteSources: SuiteSource[],
84
+ result: CoverageCheckResult,
85
+ runUrl: string,
86
+ branch?: string,
87
+ ): void {
88
+ appendFileSync(summaryFile, buildSummaryMarkdown(suiteSources, result, runUrl, branch), "utf8");
89
+ }
@@ -0,0 +1,189 @@
1
+ import { mkdtempSync, readFileSync, 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 { buildSummaryMarkdown, writeSummary } from "./step-summary.mts";
6
+ import type { SuiteSource } from "./step-summary.mts";
7
+ import type { CoverageCheckResult } from "./types.mts";
8
+
9
+ const passResult: CoverageCheckResult = {
10
+ passed: true,
11
+ buckets: [{ rule: "backend/**", threshold: 90, coverable: 10, hit: 10, passed: true, files: [] }],
12
+ informational: [],
13
+ };
14
+
15
+ const failResult: CoverageCheckResult = {
16
+ passed: false,
17
+ buckets: [{ rule: "backend/**", threshold: 90, coverable: 10, hit: 8, passed: false, files: [] }],
18
+ informational: [],
19
+ };
20
+
21
+ const freshSource: SuiteSource = {
22
+ suite: "backend",
23
+ source: "fresh",
24
+ lcov: new Map([
25
+ [
26
+ "backend/foo.mts",
27
+ new Map([
28
+ [1, 1],
29
+ [2, 0],
30
+ [3, 1],
31
+ ]),
32
+ ],
33
+ ]),
34
+ };
35
+
36
+ const storeSource: SuiteSource = {
37
+ suite: "frontend",
38
+ source: "store",
39
+ lcov: new Map([
40
+ [
41
+ "web/app.tsx",
42
+ new Map([
43
+ [10, 1],
44
+ [11, 1],
45
+ ]),
46
+ ],
47
+ ]),
48
+ };
49
+
50
+ describe("buildSummaryMarkdown", () => {
51
+ it("shows passed status in heading", () => {
52
+ const md = buildSummaryMarkdown([freshSource], passResult, "https://example.com/run/1");
53
+ expect(md).toContain("✅ passed");
54
+ });
55
+
56
+ it("shows failed status in heading", () => {
57
+ const md = buildSummaryMarkdown([freshSource], failResult, "https://example.com/run/1");
58
+ expect(md).toContain("❌ failed");
59
+ });
60
+
61
+ it("lists suite names with source labels", () => {
62
+ const md = buildSummaryMarkdown(
63
+ [freshSource, storeSource],
64
+ passResult,
65
+ "https://example.com/run/1",
66
+ );
67
+ expect(md).toContain("`backend`");
68
+ expect(md).toContain("fresh");
69
+ expect(md).toContain("`frontend`");
70
+ expect(md).toContain("store (main)");
71
+ });
72
+
73
+ it("uses the provided branch name in store source label", () => {
74
+ const md = buildSummaryMarkdown(
75
+ [storeSource],
76
+ passResult,
77
+ "https://example.com/run/1",
78
+ "feature/my-branch",
79
+ );
80
+ expect(md).toContain("store (feature/my-branch)");
81
+ });
82
+
83
+ it("shows line coverage percentage for suite", () => {
84
+ const md = buildSummaryMarkdown([freshSource], passResult, "https://example.com/run/1");
85
+ // 2/3 lines covered = 66.7%
86
+ expect(md).toContain("66.7%");
87
+ });
88
+
89
+ it("shows — for empty lcov data", () => {
90
+ const emptySource: SuiteSource = { suite: "empty", source: "fresh", lcov: new Map() };
91
+ const md = buildSummaryMarkdown([emptySource], passResult, "https://example.com/run/1");
92
+ expect(md).toContain("—");
93
+ });
94
+
95
+ it("shows rule thresholds and pass/fail status", () => {
96
+ const md = buildSummaryMarkdown([freshSource], passResult, "https://example.com/run/1");
97
+ expect(md).toContain("backend/**");
98
+ expect(md).toContain("90%");
99
+ expect(md).toContain("✅");
100
+ });
101
+
102
+ it("shows ❌ for failing rule", () => {
103
+ const md = buildSummaryMarkdown([freshSource], failResult, "https://example.com/run/1");
104
+ expect(md).toContain("❌");
105
+ });
106
+
107
+ it("shows — in rule table when bucket has no coverable lines", () => {
108
+ const noCoverableResult: CoverageCheckResult = {
109
+ passed: false,
110
+ buckets: [
111
+ { rule: "backend/**", threshold: 90, coverable: 0, hit: 0, passed: false, files: [] },
112
+ ],
113
+ informational: [],
114
+ };
115
+ const md = buildSummaryMarkdown([], noCoverableResult, "N/A");
116
+ expect(md).toContain("—");
117
+ });
118
+
119
+ it("renders empty-suite placeholder row when no sources provided", () => {
120
+ const md = buildSummaryMarkdown([], passResult, "N/A");
121
+ expect(md).toContain("| — | — | — |");
122
+ });
123
+
124
+ it("renders empty-rule placeholder row when no buckets provided", () => {
125
+ const emptyResult: CoverageCheckResult = { passed: true, buckets: [], informational: [] };
126
+ const md = buildSummaryMarkdown([], emptyResult, "N/A");
127
+ expect(md).toContain("| — | — | — | — |");
128
+ });
129
+
130
+ it("includes run link when runUrl is a valid URL", () => {
131
+ const md = buildSummaryMarkdown([], passResult, "https://example.com/run/42");
132
+ expect(md).toContain("https://example.com/run/42");
133
+ expect(md).toContain("[View run]");
134
+ });
135
+
136
+ it("omits run link when runUrl is N/A", () => {
137
+ const md = buildSummaryMarkdown([], passResult, "N/A");
138
+ expect(md).not.toContain("[View run]");
139
+ expect(md).not.toContain("N/A");
140
+ });
141
+
142
+ it("escapes pipe characters in suite names, branch names, and rule names", () => {
143
+ const pipeSource: SuiteSource = {
144
+ suite: "back|end",
145
+ source: "store",
146
+ lcov: new Map(),
147
+ };
148
+ const pipeResult: CoverageCheckResult = {
149
+ passed: true,
150
+ buckets: [
151
+ { rule: "back|end/**", threshold: 90, coverable: 10, hit: 10, passed: true, files: [] },
152
+ ],
153
+ informational: [],
154
+ };
155
+ const md = buildSummaryMarkdown([pipeSource], pipeResult, "N/A", "feat|branch");
156
+ expect(md).toContain("back\\|end");
157
+ expect(md).toContain("feat\\|branch");
158
+ });
159
+ });
160
+
161
+ describe("writeSummary", () => {
162
+ let tmpDir: string;
163
+ let summaryFile: string;
164
+
165
+ beforeEach(() => {
166
+ tmpDir = mkdtempSync(join(tmpdir(), "step-summary-"));
167
+ summaryFile = join(tmpDir, "summary.md");
168
+ writeFileSync(summaryFile, "");
169
+ });
170
+
171
+ afterEach(() => {
172
+ rmSync(tmpDir, { recursive: true, force: true });
173
+ });
174
+
175
+ it("appends summary markdown to the summary file", () => {
176
+ writeSummary(summaryFile, [freshSource], passResult, "https://example.com");
177
+ const content = readFileSync(summaryFile, "utf8");
178
+ expect(content).toContain("Coverage summary");
179
+ expect(content).toContain("backend");
180
+ });
181
+
182
+ it("appends to existing content", () => {
183
+ writeFileSync(summaryFile, "# Prior content\n");
184
+ writeSummary(summaryFile, [], passResult, "N/A");
185
+ const content = readFileSync(summaryFile, "utf8");
186
+ expect(content).toContain("# Prior content");
187
+ expect(content).toContain("Coverage summary");
188
+ });
189
+ });
@@ -0,0 +1,23 @@
1
+ import { FileSystemSuiteStore } from "./suite-store.mts";
2
+ import { S3SuiteStore } from "./s3-suite-store.mts";
3
+ import type { SuiteStore } from "./suite-store.mts";
4
+
5
+ /** Parse "bucket/prefix" or "bucket" into S3SuiteStore constructor args. */
6
+ export function parseS3Spec(spec: string): { bucket: string; prefix?: string } {
7
+ const slash = spec.indexOf("/");
8
+ const bucket = slash === -1 ? spec : spec.slice(0, slash);
9
+ if (!bucket) throw new Error(`invalid S3 spec ${JSON.stringify(spec)}: bucket must not be empty`);
10
+ if (slash === -1) return { bucket };
11
+ const prefix = spec.slice(slash + 1).replace(/^\/+|\/+$/g, "");
12
+ return prefix ? { bucket, prefix } : { bucket };
13
+ }
14
+
15
+ /** Build a SuiteStore from CLI flag values. Returns null if neither is set. */
16
+ export function makeStore(opts: { fs?: string | null; s3?: string | null }): SuiteStore | null {
17
+ if (opts.s3 != null) {
18
+ const { bucket, prefix } = parseS3Spec(opts.s3);
19
+ return new S3SuiteStore({ bucket, prefix });
20
+ }
21
+ if (opts.fs != null) return new FileSystemSuiteStore(opts.fs);
22
+ return null;
23
+ }
@@ -0,0 +1,67 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { parseS3Spec, makeStore } from "./store-factory.mts";
3
+ import { FileSystemSuiteStore } from "./suite-store.mts";
4
+ import { S3SuiteStore } from "./s3-suite-store.mts";
5
+
6
+ describe("parseS3Spec", () => {
7
+ it("returns bucket-only when no slash present", () => {
8
+ expect(parseS3Spec("my-bucket")).toEqual({ bucket: "my-bucket" });
9
+ });
10
+
11
+ it("splits on first slash into bucket and prefix", () => {
12
+ expect(parseS3Spec("my-bucket/my/deep/prefix")).toEqual({
13
+ bucket: "my-bucket",
14
+ prefix: "my/deep/prefix",
15
+ });
16
+ });
17
+
18
+ it("handles a single-segment prefix", () => {
19
+ expect(parseS3Spec("bucket/prefix")).toEqual({ bucket: "bucket", prefix: "prefix" });
20
+ });
21
+
22
+ it("throws when spec starts with a slash (empty bucket)", () => {
23
+ expect(() => parseS3Spec("/prefix")).toThrow("bucket must not be empty");
24
+ });
25
+
26
+ it("throws when spec is empty", () => {
27
+ expect(() => parseS3Spec("")).toThrow("bucket must not be empty");
28
+ });
29
+
30
+ it("omits prefix when trailing slash yields empty prefix", () => {
31
+ expect(parseS3Spec("bucket/")).toEqual({ bucket: "bucket" });
32
+ });
33
+
34
+ it("normalizes double slashes in prefix", () => {
35
+ expect(parseS3Spec("bucket//foo")).toEqual({ bucket: "bucket", prefix: "foo" });
36
+ });
37
+ });
38
+
39
+ describe("makeStore", () => {
40
+ it("returns null when neither fs nor s3 is provided", () => {
41
+ expect(makeStore({ fs: null, s3: null })).toBeNull();
42
+ });
43
+
44
+ it("returns null when both are undefined", () => {
45
+ expect(makeStore({})).toBeNull();
46
+ });
47
+
48
+ it("returns a FileSystemSuiteStore for an fs path", () => {
49
+ expect(makeStore({ fs: "/tmp/store" })).toBeInstanceOf(FileSystemSuiteStore);
50
+ });
51
+
52
+ it("returns an S3SuiteStore for an s3 spec", () => {
53
+ expect(makeStore({ s3: "my-bucket" })).toBeInstanceOf(S3SuiteStore);
54
+ });
55
+
56
+ it("returns an S3SuiteStore for an s3 spec with prefix", () => {
57
+ expect(makeStore({ s3: "my-bucket/prefix" })).toBeInstanceOf(S3SuiteStore);
58
+ });
59
+
60
+ it("prefers s3 over fs when both are provided", () => {
61
+ expect(makeStore({ fs: "/tmp/store", s3: "my-bucket" })).toBeInstanceOf(S3SuiteStore);
62
+ });
63
+
64
+ it("throws when s3 spec is an empty string (not silently treated as absent)", () => {
65
+ expect(() => makeStore({ s3: "" })).toThrow("bucket must not be empty");
66
+ });
67
+ });