@patronage/factory-ci 0.2.1 → 1.0.0-alpha.13

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,448 @@
1
+ import type { PinnedAction } from "./actions.ts";
2
+ import type { WorkflowStep } from "./factory-workflow.ts";
3
+ import { assertPinnedAction } from "./pinned-action.ts";
4
+
5
+ export const FACTORY_PUSH_IDENTITY_SCHEMA_VERSION = 1;
6
+ export const FACTORY_PUSH_IDENTITY_ARTIFACT_PREFIX = "factory-push-identity";
7
+ export const FACTORY_PUSH_IDENTITY_RECORD_STEP_ID =
8
+ "factory_push_identity_record";
9
+ export const FACTORY_PUSH_IDENTITY_LOOKUP_STEP_ID =
10
+ "factory_push_identity_lookup";
11
+ export const FACTORY_PUSH_IDENTITY_DOWNLOAD_STEP_ID =
12
+ "factory_push_identity_download";
13
+ export const FACTORY_PUSH_IDENTITY_CHECKOUT_STEP_ID =
14
+ "factory_push_identity_checkout";
15
+ export const FACTORY_PUSH_IDENTITY_VALIDATE_STEP_ID = "factory_push_identity";
16
+
17
+ /** Versioned document uploaded by a push-triggered verification run. */
18
+ export interface FactoryPushIdentityEnvelope {
19
+ readonly after: string;
20
+ readonly before: string;
21
+ readonly repository: string;
22
+ readonly runId: string;
23
+ readonly schemaVersion: typeof FACTORY_PUSH_IDENTITY_SCHEMA_VERSION;
24
+ }
25
+
26
+ export type FactoryPushIdentityDisposition = "refused" | "usable";
27
+
28
+ const ARTIFACT_MAX_BYTES = 16_384;
29
+ const ENVELOPE_MAX_BYTES = 4096;
30
+
31
+ const expression = (value: string): string => `\${{ ${value} }}`;
32
+ const artifactName = (runId: string): string =>
33
+ `${FACTORY_PUSH_IDENTITY_ARTIFACT_PREFIX}-${runId}`;
34
+
35
+ const producerScript = String.raw`identity="$RUNNER_TEMP/factory-push-identity.json"
36
+ produced=false
37
+ reason='producer_error'
38
+ before=''
39
+ event_after=''
40
+ rm -f "$identity"
41
+
42
+ is_hex_sha() {
43
+ [ "$(printf '%s' "$1" | wc -c | tr -d '[:space:]')" -eq 40 ] &&
44
+ case "$1" in *[!0-9a-f]*) false;; *) true;; esac
45
+ }
46
+
47
+ is_nonzero_sha() {
48
+ is_hex_sha "$1" && [ "$1" != '0000000000000000000000000000000000000000' ]
49
+ }
50
+
51
+ if [ "$GITHUB_EVENT_NAME" != 'push' ]; then
52
+ reason='not_push_event'
53
+ elif ! printf '%s' "$GITHUB_REPOSITORY" | grep -Eq '^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$'; then
54
+ reason='repository_invalid'
55
+ elif ! printf '%s' "$GITHUB_RUN_ID" | grep -Eq '^[1-9][0-9]*$'; then
56
+ reason='run_id_invalid'
57
+ elif ! before="$(jq -er '.before | select(type == "string")' "$GITHUB_EVENT_PATH" 2>/dev/null)"; then
58
+ reason='event_before_missing'
59
+ elif ! event_after="$(jq -er '.after | select(type == "string")' "$GITHUB_EVENT_PATH" 2>/dev/null)"; then
60
+ reason='event_after_missing'
61
+ elif ! is_hex_sha "$before"; then
62
+ reason='event_before_invalid'
63
+ elif ! is_nonzero_sha "$event_after" || ! is_nonzero_sha "$GITHUB_SHA"; then
64
+ reason='event_after_invalid'
65
+ elif [ "$event_after" != "$GITHUB_SHA" ]; then
66
+ reason='event_after_mismatch'
67
+ elif jq -cn \
68
+ --argjson schemaVersion '${FACTORY_PUSH_IDENTITY_SCHEMA_VERSION}' \
69
+ --arg repository "$GITHUB_REPOSITORY" \
70
+ --arg runId "$GITHUB_RUN_ID" \
71
+ --arg before "$before" \
72
+ --arg after "$event_after" \
73
+ '{schemaVersion: $schemaVersion, repository: $repository, runId: $runId, before: $before, after: $after}' > "$identity"; then
74
+ produced=true
75
+ reason='produced'
76
+ fi
77
+
78
+ printf 'produced=%s\nreason=%s\n' "$produced" "$reason" >> "$GITHUB_OUTPUT"
79
+ {
80
+ printf '## Exact push identity producer\n\n'
81
+ printf -- '- Status: %s\n' "$reason"
82
+ printf -- '- Repository/run: %s / %s\n' "$GITHUB_REPOSITORY" "$GITHUB_RUN_ID"
83
+ if [ "$produced" = true ]; then
84
+ printf -- '- Bound push: %s → %s\n' "$before" "$event_after"
85
+ else
86
+ printf -- '- Identity transport was not produced; downstream classification will refuse withdrawal.\n'
87
+ fi
88
+ } >> "$GITHUB_STEP_SUMMARY"`;
89
+
90
+ const lookupScript = String.raw`status='refused'
91
+ reason='artifact_lookup_failed'
92
+ artifact_id=''
93
+ matches="$RUNNER_TEMP/factory-push-identity-artifacts.tsv"
94
+ artifact_name='${FACTORY_PUSH_IDENTITY_ARTIFACT_PREFIX}-'"$EXPECTED_RUN_ID"
95
+ rm -f "$matches"
96
+
97
+ is_sha() {
98
+ [ "$(printf '%s' "$1" | wc -c | tr -d '[:space:]')" -eq 40 ] &&
99
+ [ "$1" != '0000000000000000000000000000000000000000' ] &&
100
+ case "$1" in *[!0-9a-f]*) false;; *) true;; esac
101
+ }
102
+
103
+ if [ "$EXPECTED_EVENT_NAME" != 'workflow_run' ]; then
104
+ reason='event_not_workflow_run'
105
+ elif [ "$EXPECTED_WORKFLOW_EVENT" != 'push' ]; then
106
+ reason='triggering_workflow_not_push'
107
+ elif [ "$EXPECTED_CONCLUSION" != 'success' ]; then
108
+ reason='triggering_workflow_not_successful'
109
+ elif [ "$EXPECTED_WORKFLOW_REPOSITORY" != "$EXPECTED_REPOSITORY" ]; then
110
+ reason='triggering_repository_mismatch'
111
+ elif ! printf '%s' "$EXPECTED_REPOSITORY" | grep -Eq '^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$'; then
112
+ reason='triggering_repository_invalid'
113
+ elif ! printf '%s' "$EXPECTED_RUN_ID" | grep -Eq '^[1-9][0-9]*$'; then
114
+ reason='triggering_run_id_invalid'
115
+ elif ! is_sha "$EXPECTED_AFTER_SHA"; then
116
+ reason='triggering_head_sha_invalid'
117
+ elif ! gh api --paginate \
118
+ "repos/$EXPECTED_REPOSITORY/actions/runs/$EXPECTED_RUN_ID/artifacts?per_page=100" \
119
+ --jq ".artifacts[] | select(.name == \"$artifact_name\") | [.id, .size_in_bytes, .expired] | @tsv" \
120
+ > "$matches" 2>/dev/null; then
121
+ reason='artifact_lookup_failed'
122
+ else
123
+ count="$(awk 'END { print NR + 0 }' "$matches")"
124
+ if [ "$count" -eq 0 ]; then
125
+ reason='artifact_missing'
126
+ elif [ "$count" -ne 1 ]; then
127
+ reason='artifact_duplicate'
128
+ else
129
+ IFS="$(printf '\t')" read -r artifact_id artifact_size artifact_expired < "$matches" || true
130
+ if ! printf '%s' "$artifact_id" | grep -Eq '^[1-9][0-9]*$' ||
131
+ ! printf '%s' "$artifact_size" | grep -Eq '^[0-9]+$' ||
132
+ { [ "$artifact_expired" != 'false' ] && [ "$artifact_expired" != 'true' ]; }; then
133
+ reason='artifact_metadata_invalid'
134
+ artifact_id=''
135
+ elif [ "$artifact_expired" = 'true' ]; then
136
+ reason='artifact_expired'
137
+ artifact_id=''
138
+ elif [ "$artifact_size" -gt '${ARTIFACT_MAX_BYTES}' ]; then
139
+ reason='artifact_oversized'
140
+ artifact_id=''
141
+ else
142
+ status='available'
143
+ reason='artifact_available'
144
+ fi
145
+ fi
146
+ fi
147
+
148
+ printf 'status=%s\nreason=%s\nartifact_id=%s\n' "$status" "$reason" "$artifact_id" >> "$GITHUB_OUTPUT"`;
149
+
150
+ const validationScript = String.raw`disposition='refused'
151
+ reason='validation_error'
152
+ before=''
153
+ after=''
154
+ identity_directory="$RUNNER_TEMP/factory-push-identity"
155
+ identity="$identity_directory/factory-push-identity.json"
156
+ artifact_name='${FACTORY_PUSH_IDENTITY_ARTIFACT_PREFIX}-'"$EXPECTED_RUN_ID"
157
+
158
+ is_sha() {
159
+ [ "$(printf '%s' "$1" | wc -c | tr -d '[:space:]')" -eq 40 ] &&
160
+ [ "$1" != '0000000000000000000000000000000000000000' ] &&
161
+ case "$1" in *[!0-9a-f]*) false;; *) true;; esac
162
+ }
163
+
164
+ if [ "$EXPECTED_EVENT_NAME" != 'workflow_run' ]; then
165
+ reason='event_not_workflow_run'
166
+ elif [ "$EXPECTED_WORKFLOW_EVENT" != 'push' ]; then
167
+ reason='triggering_workflow_not_push'
168
+ elif [ "$EXPECTED_CONCLUSION" != 'success' ]; then
169
+ reason='triggering_workflow_not_successful'
170
+ elif [ "$EXPECTED_WORKFLOW_REPOSITORY" != "$EXPECTED_REPOSITORY" ]; then
171
+ reason='triggering_repository_mismatch'
172
+ elif ! printf '%s' "$EXPECTED_REPOSITORY" | grep -Eq '^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$'; then
173
+ reason='triggering_repository_invalid'
174
+ elif ! printf '%s' "$EXPECTED_RUN_ID" | grep -Eq '^[1-9][0-9]*$'; then
175
+ reason='triggering_run_id_invalid'
176
+ elif ! is_sha "$EXPECTED_AFTER_SHA"; then
177
+ reason='triggering_head_sha_invalid'
178
+ elif [ "$LOOKUP_STATUS" != 'available' ]; then
179
+ if [ -n "$LOOKUP_REASON" ]; then
180
+ reason="$LOOKUP_REASON"
181
+ else
182
+ reason='artifact_lookup_failed'
183
+ fi
184
+ elif [ "$DOWNLOAD_OUTCOME" != 'success' ]; then
185
+ reason='artifact_download_failed'
186
+ elif [ "$CHECKOUT_OUTCOME" != 'success' ]; then
187
+ reason='checkout_failed'
188
+ elif [ ! -d "$identity_directory" ]; then
189
+ reason='artifact_contents_missing'
190
+ else
191
+ entry_count="$(find "$identity_directory" -mindepth 1 -maxdepth 1 -print 2>/dev/null | awk 'END { print NR + 0 }')"
192
+ if [ "$entry_count" -ne 1 ] || [ ! -f "$identity" ] || [ -L "$identity" ]; then
193
+ reason='artifact_contents_invalid'
194
+ else
195
+ envelope_size="$(wc -c < "$identity" | tr -d '[:space:]')"
196
+ if ! printf '%s' "$envelope_size" | grep -Eq '^[0-9]+$'; then
197
+ reason='envelope_size_unreadable'
198
+ elif [ "$envelope_size" -gt '${ENVELOPE_MAX_BYTES}' ]; then
199
+ reason='envelope_oversized'
200
+ elif ! jq -e --argjson schemaVersion '${FACTORY_PUSH_IDENTITY_SCHEMA_VERSION}' '
201
+ type == "object" and
202
+ keys == ["after", "before", "repository", "runId", "schemaVersion"] and
203
+ .schemaVersion == $schemaVersion and
204
+ (.repository | type == "string") and
205
+ (.runId | type == "string" and test("^[1-9][0-9]*$")) and
206
+ (.before | type == "string" and test("^[0-9a-f]{40}$") and (test("^0{40}$") | not)) and
207
+ (.after | type == "string" and test("^[0-9a-f]{40}$") and (test("^0{40}$") | not))
208
+ ' "$identity" >/dev/null 2>&1; then
209
+ reason='envelope_malformed'
210
+ else
211
+ repository="$(jq -r '.repository' "$identity")"
212
+ run_id="$(jq -r '.runId' "$identity")"
213
+ before="$(jq -r '.before' "$identity")"
214
+ after="$(jq -r '.after' "$identity")"
215
+
216
+ if [ "$repository" != "$EXPECTED_REPOSITORY" ]; then
217
+ reason='envelope_repository_mismatch'
218
+ elif [ "$run_id" != "$EXPECTED_RUN_ID" ]; then
219
+ reason='envelope_run_id_mismatch'
220
+ elif [ "$after" != "$EXPECTED_AFTER_SHA" ]; then
221
+ reason='envelope_after_mismatch'
222
+ elif ! actual_head="$(git rev-parse --verify HEAD 2>/dev/null)" || [ "$actual_head" != "$EXPECTED_AFTER_SHA" ]; then
223
+ reason='checkout_head_mismatch'
224
+ elif ! git cat-file -e "$before^{commit}" 2>/dev/null || ! git cat-file -e "$after^{commit}" 2>/dev/null; then
225
+ reason='envelope_commit_unreachable'
226
+ elif [ "$before" = "$after" ]; then
227
+ reason='envelope_commits_contradictory'
228
+ elif ! git merge-base --is-ancestor "$before" "$after"; then
229
+ reason='before_not_ancestor'
230
+ else
231
+ disposition='usable'
232
+ reason='identity_bound'
233
+ fi
234
+ fi
235
+ fi
236
+ fi
237
+
238
+ if [ "$disposition" != 'usable' ]; then
239
+ before=''
240
+ after=''
241
+ fi
242
+
243
+ provenance="$(jq -cn \
244
+ --argjson schemaVersion '${FACTORY_PUSH_IDENTITY_SCHEMA_VERSION}' \
245
+ --arg disposition "$disposition" \
246
+ --arg reason "$reason" \
247
+ --arg repository "$EXPECTED_REPOSITORY" \
248
+ --arg runId "$EXPECTED_RUN_ID" \
249
+ --arg expectedAfter "$EXPECTED_AFTER_SHA" \
250
+ --arg artifactName "$artifact_name" \
251
+ --arg artifactId "$ARTIFACT_ID" \
252
+ --arg before "$before" \
253
+ --arg after "$after" \
254
+ '{schemaVersion: $schemaVersion, disposition: $disposition, reason: $reason, repository: $repository, runId: $runId, expectedAfter: $expectedAfter, artifactName: $artifactName, artifactId: (if $artifactId == "" then null else $artifactId end), before: (if $before == "" then null else $before end), after: (if $after == "" then null else $after end)}')"
255
+
256
+ printf 'disposition=%s\nreason=%s\nbefore=%s\nafter=%s\nprovenance=%s\n' \
257
+ "$disposition" "$reason" "$before" "$after" "$provenance" >> "$GITHUB_OUTPUT"
258
+ {
259
+ printf '## Exact push identity\n\n'
260
+ printf -- '- Disposition: %s\n' "$disposition"
261
+ printf -- '- Reason: %s\n' "$reason"
262
+ printf -- '- Repository/run: %s / %s\n' "$EXPECTED_REPOSITORY" "$EXPECTED_RUN_ID"
263
+ printf -- '- Expected verified head: %s\n' "$EXPECTED_AFTER_SHA"
264
+ if [ "$disposition" = 'usable' ]; then
265
+ printf -- '- Bound push: %s → %s\n' "$before" "$after"
266
+ else
267
+ printf -- '- Production impact classification refused; every consumer-owned production target remains demanded.\n'
268
+ fi
269
+ printf '\nFactory telemetry provenance: %s\n' "$provenance"
270
+ } >> "$GITHUB_STEP_SUMMARY"`;
271
+
272
+ export interface FactoryPushIdentityProducerOptions {
273
+ /** Explicit caller-owned upload-artifact pin from its workflow artifact. */
274
+ readonly uploadArtifact: PinnedAction;
275
+ /** Optional consumer trigger policy combined with the required push event. */
276
+ readonly if?: string;
277
+ }
278
+
279
+ export interface FactoryPushIdentityProducer {
280
+ readonly artifactName: string;
281
+ readonly steps: readonly WorkflowStep[];
282
+ }
283
+
284
+ /**
285
+ * Record and upload the exact push-event identity without changing the Verify
286
+ * result when transport is unavailable. Place these steps after verification.
287
+ */
288
+ export const factoryPushIdentityProducer = (
289
+ options: FactoryPushIdentityProducerOptions
290
+ ): FactoryPushIdentityProducer => {
291
+ assertPinnedAction(
292
+ "uploadArtifact",
293
+ options.uploadArtifact,
294
+ "actions/upload-artifact"
295
+ );
296
+ const condition = options.if
297
+ ? `github.event_name == 'push' && (${options.if})`
298
+ : "github.event_name == 'push'";
299
+ const name = artifactName(expression("github.run_id"));
300
+ return Object.freeze({
301
+ artifactName: name,
302
+ steps: Object.freeze([
303
+ {
304
+ continueOnError: true,
305
+ id: FACTORY_PUSH_IDENTITY_RECORD_STEP_ID,
306
+ if: condition,
307
+ name: "Record exact push identity",
308
+ run: producerScript,
309
+ },
310
+ {
311
+ continueOnError: true,
312
+ if: `${condition} && steps.${FACTORY_PUSH_IDENTITY_RECORD_STEP_ID}.outputs.produced == 'true'`,
313
+ name: "Upload exact push identity",
314
+ uses: options.uploadArtifact.uses,
315
+ with: {
316
+ "if-no-files-found": "error",
317
+ name,
318
+ path: `${expression("runner.temp")}/factory-push-identity.json`,
319
+ "retention-days": "7",
320
+ },
321
+ },
322
+ ]),
323
+ });
324
+ };
325
+
326
+ export interface FactoryPushIdentityConsumer {
327
+ /** Job outputs suitable for a caller-owned decision job and telemetry. */
328
+ readonly outputs: Readonly<
329
+ Record<"after" | "before" | "disposition" | "provenance" | "reason", string>
330
+ >;
331
+ readonly requiredPermissions: Readonly<{
332
+ actions: "read";
333
+ contents: "read";
334
+ }>;
335
+ readonly steps: readonly WorkflowStep[];
336
+ /** Run classification only when exact identity transport is usable. */
337
+ readonly usableIf: string;
338
+ }
339
+
340
+ export interface FactoryPushIdentityConsumerOptions {
341
+ /** Explicit caller-owned checkout pin from its action family. */
342
+ readonly checkout: PinnedAction;
343
+ /** Explicit caller-owned download-artifact pin from its workflow artifact. */
344
+ readonly downloadArtifact: PinnedAction;
345
+ }
346
+
347
+ /**
348
+ * Download and validate identity from exactly the triggering workflow run in
349
+ * the current repository. Every failure becomes a typed refusal; consumers
350
+ * retain deploy policy, credentials, commands, topology, and convergence.
351
+ */
352
+ export const factoryPushIdentityConsumer = (
353
+ options: FactoryPushIdentityConsumerOptions
354
+ ): FactoryPushIdentityConsumer => {
355
+ assertPinnedAction("checkout", options.checkout, "actions/checkout");
356
+ assertPinnedAction(
357
+ "downloadArtifact",
358
+ options.downloadArtifact,
359
+ "actions/download-artifact"
360
+ );
361
+ const currentRepository = expression("github.repository");
362
+ const triggeringRun = expression("github.event.workflow_run.id");
363
+ const triggeringHead = expression("github.event.workflow_run.head_sha");
364
+ const lookupOutput = (name: string): string =>
365
+ expression(`steps.${FACTORY_PUSH_IDENTITY_LOOKUP_STEP_ID}.outputs.${name}`);
366
+ const validateOutput = (name: string): string =>
367
+ expression(
368
+ `steps.${FACTORY_PUSH_IDENTITY_VALIDATE_STEP_ID}.outputs.${name}`
369
+ );
370
+
371
+ const contextEnvironment = {
372
+ EXPECTED_AFTER_SHA: triggeringHead,
373
+ EXPECTED_CONCLUSION: expression("github.event.workflow_run.conclusion"),
374
+ EXPECTED_EVENT_NAME: expression("github.event_name"),
375
+ EXPECTED_REPOSITORY: currentRepository,
376
+ EXPECTED_RUN_ID: triggeringRun,
377
+ EXPECTED_WORKFLOW_EVENT: expression("github.event.workflow_run.event"),
378
+ EXPECTED_WORKFLOW_REPOSITORY: expression(
379
+ "github.event.workflow_run.repository.full_name"
380
+ ),
381
+ };
382
+ const steps: readonly WorkflowStep[] = [
383
+ {
384
+ continueOnError: true,
385
+ env: {
386
+ GH_TOKEN: expression("github.token"),
387
+ ...contextEnvironment,
388
+ },
389
+ id: FACTORY_PUSH_IDENTITY_LOOKUP_STEP_ID,
390
+ name: "Resolve exact push identity artifact",
391
+ run: lookupScript,
392
+ },
393
+ {
394
+ continueOnError: true,
395
+ id: FACTORY_PUSH_IDENTITY_DOWNLOAD_STEP_ID,
396
+ if: `steps.${FACTORY_PUSH_IDENTITY_LOOKUP_STEP_ID}.outputs.status == 'available'`,
397
+ name: "Download exact push identity",
398
+ uses: options.downloadArtifact.uses,
399
+ with: {
400
+ "artifact-ids": lookupOutput("artifact_id"),
401
+ "github-token": expression("github.token"),
402
+ path: `${expression("runner.temp")}/factory-push-identity`,
403
+ repository: currentRepository,
404
+ "run-id": triggeringRun,
405
+ },
406
+ },
407
+ {
408
+ continueOnError: true,
409
+ id: FACTORY_PUSH_IDENTITY_CHECKOUT_STEP_ID,
410
+ if: `steps.${FACTORY_PUSH_IDENTITY_LOOKUP_STEP_ID}.outputs.status == 'available'`,
411
+ name: "Checkout exact verified head",
412
+ uses: options.checkout.uses,
413
+ with: { "fetch-depth": "0", ref: triggeringHead },
414
+ },
415
+ {
416
+ continueOnError: true,
417
+ env: {
418
+ ARTIFACT_ID: lookupOutput("artifact_id"),
419
+ CHECKOUT_OUTCOME: expression(
420
+ `steps.${FACTORY_PUSH_IDENTITY_CHECKOUT_STEP_ID}.outcome`
421
+ ),
422
+ DOWNLOAD_OUTCOME: expression(
423
+ `steps.${FACTORY_PUSH_IDENTITY_DOWNLOAD_STEP_ID}.outcome`
424
+ ),
425
+ LOOKUP_REASON: lookupOutput("reason"),
426
+ LOOKUP_STATUS: lookupOutput("status"),
427
+ ...contextEnvironment,
428
+ },
429
+ id: FACTORY_PUSH_IDENTITY_VALIDATE_STEP_ID,
430
+ if: "always()",
431
+ name: "Validate exact push identity",
432
+ run: validationScript,
433
+ },
434
+ ];
435
+
436
+ return Object.freeze({
437
+ outputs: Object.freeze({
438
+ after: validateOutput("after"),
439
+ before: validateOutput("before"),
440
+ disposition: validateOutput("disposition"),
441
+ provenance: validateOutput("provenance"),
442
+ reason: validateOutput("reason"),
443
+ }),
444
+ requiredPermissions: Object.freeze({ actions: "read", contents: "read" }),
445
+ steps: Object.freeze(steps),
446
+ usableIf: `steps.${FACTORY_PUSH_IDENTITY_VALIDATE_STEP_ID}.outputs.disposition == 'usable'`,
447
+ });
448
+ };
@@ -0,0 +1,208 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { readVitestProfileDocument } from "./vitest-profile-reader.ts";
4
+ import type { VitestProfile } from "./vitest-profile.ts";
5
+
6
+ const durations = {
7
+ maximum: 16_200,
8
+ mean: 15_800,
9
+ median: 15_700,
10
+ minimum: 15_300,
11
+ };
12
+
13
+ /** A document shaped exactly as `runVitestProfile` writes it. */
14
+ const validProfile = (): VitestProfile => ({
15
+ command: ["vitest", "run", "--reporter=json", "--maxWorkers=2"],
16
+ endedAt: "2026-08-06T12:01:00.000Z",
17
+ environment: {
18
+ arch: "x64",
19
+ availableParallelism: 4,
20
+ cpuCount: 4,
21
+ cpuModel: "AMD EPYC 9R45",
22
+ gitDirty: false,
23
+ gitHead: "0123456789abcdef0123456789abcdef01234567",
24
+ node: "v24.0.0",
25
+ osRelease: "6.8.0",
26
+ platform: "linux",
27
+ totalMemoryBytes: 16_000_000_000,
28
+ vitest: "3.0.0",
29
+ },
30
+ options: { maxWorkers: 2, samples: 3, slowLimit: 20 },
31
+ rawReportDirectory: "/tmp/profile.raw/abc",
32
+ runs: [
33
+ {
34
+ counts: {
35
+ failed: 0,
36
+ passed: 900,
37
+ pending: 0,
38
+ suites: 80,
39
+ tests: 900,
40
+ todo: 0,
41
+ },
42
+ durationMs: 15_300,
43
+ endedAt: "2026-08-06T12:00:20.000Z",
44
+ exitCode: 0,
45
+ failure: null,
46
+ files: [{ durationMs: 900, path: "src/slow.test.ts", status: "passed" }],
47
+ reportAvailable: true,
48
+ sample: 1,
49
+ startedAt: "2026-08-06T12:00:00.000Z",
50
+ tests: [
51
+ {
52
+ durationMs: 450,
53
+ file: "src/slow.test.ts",
54
+ name: "slow case",
55
+ status: "passed",
56
+ },
57
+ ],
58
+ },
59
+ ],
60
+ schemaVersion: 1,
61
+ startedAt: "2026-08-06T12:00:00.000Z",
62
+ summary: {
63
+ durationMs: { ...durations },
64
+ slowFiles: [
65
+ { durationMs: { ...durations }, path: "src/slow.test.ts", samples: 3 },
66
+ ],
67
+ slowTests: [
68
+ {
69
+ durationMs: { ...durations },
70
+ file: "src/slow.test.ts",
71
+ name: "slow case",
72
+ samples: 3,
73
+ },
74
+ ],
75
+ },
76
+ tool: "factory-ci-vitest-profile",
77
+ });
78
+
79
+ describe("readVitestProfileDocument", () => {
80
+ it("accepts a document the writer emitted", () => {
81
+ const result = readVitestProfileDocument(validProfile());
82
+
83
+ expect(result.kind).toBe("profile");
84
+ if (result.kind === "profile") {
85
+ expect(result.profile.environment.cpuModel).toBe("AMD EPYC 9R45");
86
+ expect(result.profile.summary.durationMs.median).toBe(15_700);
87
+ }
88
+ });
89
+
90
+ it("names the tool mismatch instead of guessing", () => {
91
+ const result = readVitestProfileDocument({
92
+ ...validProfile(),
93
+ tool: "someone-elses-artifact",
94
+ });
95
+
96
+ expect(result).toEqual({
97
+ kind: "unrecognized",
98
+ reason:
99
+ 'tool is "someone-elses-artifact" rather than "factory-ci-vitest-profile"',
100
+ });
101
+ });
102
+
103
+ it("refuses a future schema version rather than misreading it", () => {
104
+ const result = readVitestProfileDocument({
105
+ ...validProfile(),
106
+ schemaVersion: 2,
107
+ });
108
+
109
+ expect(result).toEqual({
110
+ kind: "unrecognized",
111
+ reason: "schemaVersion is 2 rather than 1",
112
+ });
113
+ });
114
+
115
+ it("refuses a document whose environment lost the cpuModel field", () => {
116
+ const document = validProfile() as unknown as {
117
+ environment: Record<string, unknown>;
118
+ };
119
+ delete document.environment.cpuModel;
120
+
121
+ expect(readVitestProfileDocument(document)).toEqual({
122
+ kind: "unrecognized",
123
+ reason: "environment.cpuModel is neither a string nor null",
124
+ });
125
+ });
126
+
127
+ it("refuses a run without a finite duration", () => {
128
+ const document = validProfile();
129
+ (document.runs[0] as unknown as Record<string, unknown>).durationMs =
130
+ "fast";
131
+
132
+ expect(readVitestProfileDocument(document)).toEqual({
133
+ kind: "unrecognized",
134
+ reason: "runs[0].durationMs is not a finite number",
135
+ });
136
+ });
137
+
138
+ it("refuses a summary without duration statistics", () => {
139
+ const document = validProfile() as unknown as {
140
+ summary: { durationMs: Record<string, unknown> };
141
+ };
142
+ delete document.summary.durationMs.median;
143
+
144
+ expect(readVitestProfileDocument(document)).toEqual({
145
+ kind: "unrecognized",
146
+ reason: "summary.durationMs.median is not a finite number",
147
+ });
148
+ });
149
+
150
+ it("refuses empty counts rather than serving undefined fields", () => {
151
+ const document = validProfile();
152
+ (document.runs[0] as unknown as Record<string, unknown>).counts = {};
153
+
154
+ expect(readVitestProfileDocument(document)).toEqual({
155
+ kind: "unrecognized",
156
+ reason: "runs[0].counts.failed is not a finite number",
157
+ });
158
+ });
159
+
160
+ it("refuses a null slowFiles entry rather than crashing projection", () => {
161
+ const document = validProfile() as unknown as {
162
+ summary: { slowFiles: unknown[] };
163
+ };
164
+ document.summary.slowFiles = [null];
165
+
166
+ expect(readVitestProfileDocument(document)).toEqual({
167
+ kind: "unrecognized",
168
+ reason: "summary.slowFiles[0] is not an object",
169
+ });
170
+ });
171
+
172
+ it("refuses a slow test whose duration summary is incomplete", () => {
173
+ const document = validProfile() as unknown as {
174
+ summary: { slowTests: { durationMs: Record<string, unknown> }[] };
175
+ };
176
+ const [slowTest] = document.summary.slowTests;
177
+ if (slowTest) {
178
+ delete slowTest.durationMs.median;
179
+ }
180
+
181
+ expect(readVitestProfileDocument(document)).toEqual({
182
+ kind: "unrecognized",
183
+ reason: "summary.slowTests[0].durationMs.median is not a finite number",
184
+ });
185
+ });
186
+
187
+ it("refuses a slow file without a path string", () => {
188
+ const document = validProfile() as unknown as {
189
+ summary: { slowFiles: Record<string, unknown>[] };
190
+ };
191
+ const [slowFile] = document.summary.slowFiles;
192
+ if (slowFile) {
193
+ delete slowFile.path;
194
+ }
195
+
196
+ expect(readVitestProfileDocument(document)).toEqual({
197
+ kind: "unrecognized",
198
+ reason: "summary.slowFiles[0].path is not a string",
199
+ });
200
+ });
201
+
202
+ it("refuses non-object input", () => {
203
+ expect(readVitestProfileDocument(null)).toEqual({
204
+ kind: "unrecognized",
205
+ reason: "the document is not an object",
206
+ });
207
+ });
208
+ });