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.
- 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
|
@@ -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(
|
|
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
|
|
119
|
+
store,
|
|
48
120
|
artifacts: artifactsDir,
|
|
49
121
|
stripPrefixes: [],
|
|
50
|
-
sha:
|
|
51
|
-
|
|
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
|
|
137
|
+
store,
|
|
66
138
|
artifacts: artifactsDir,
|
|
67
139
|
stripPrefixes: [],
|
|
68
140
|
sha: "abc123",
|
|
69
|
-
|
|
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
|
|
164
|
+
store,
|
|
94
165
|
artifacts: artifactsDir,
|
|
95
166
|
stripPrefixes: [],
|
|
96
|
-
sha:
|
|
97
|
-
|
|
167
|
+
sha: "abc123",
|
|
168
|
+
branch: "main",
|
|
98
169
|
}),
|
|
99
170
|
).toBe(0);
|
|
100
171
|
|
|
101
|
-
const
|
|
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
|
|
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
|
-
"--
|
|
147
|
-
"
|
|
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
|
});
|
package/src/coverage-check.mts
CHANGED
|
@@ -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,
|
package/src/github-comment.mts
CHANGED
|
@@ -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
|
|
49
|
-
*
|
|
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;
|
|
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("
|
|
33
|
+
it("deletes an existing comment on pass", async () => {
|
|
35
34
|
const gh = makeGh({ "issues/42/comments --paginate": "99\n" });
|
|
36
|
-
await upsertComment(
|
|
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("
|
|
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(
|
|
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
|
-
}
|
package/src/report.test.mts
CHANGED
|
@@ -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
|
+
}
|