@patronage/factory-ci 1.0.0-alpha.6 → 1.0.0-alpha.8
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/dist/index.d.ts +28 -1
- package/dist/index.js +123 -2
- package/package.json +1 -1
- package/src/index.ts +5 -0
- package/src/proof-reuse-gate.ts +10 -1
- package/src/vitest-profile-reader.test.ts +208 -0
- package/src/vitest-profile-reader.ts +220 -0
package/dist/index.d.ts
CHANGED
|
@@ -324,6 +324,13 @@ declare const FACTORY_PROOF_GATE_CHECK_NAME = "patronage-factory/pr-verify";
|
|
|
324
324
|
declare const FACTORY_PROOF_GATE_APP_ID = "4314840";
|
|
325
325
|
/** Step id the guard condition refers to. */
|
|
326
326
|
declare const FACTORY_PROOF_GATE_STEP_ID = "factory-proof";
|
|
327
|
+
/**
|
|
328
|
+
* Human-visible name of the gate step as the Actions jobs API serves it. A
|
|
329
|
+
* read-only run analyzer (`psf ci:analyze`, #647) matches on this name to
|
|
330
|
+
* classify a run as proof-reuse versus full fallback, so it is exported from
|
|
331
|
+
* exactly the module that writes it — matching on a re-typed copy would drift.
|
|
332
|
+
*/
|
|
333
|
+
declare const FACTORY_PROOF_GATE_STEP_NAME = "Check for factory proof of this head";
|
|
327
334
|
/**
|
|
328
335
|
* The shell the gate runs under, and it is a correctness requirement rather
|
|
329
336
|
* than a preference.
|
|
@@ -750,6 +757,26 @@ declare const writeVitestProfile: (outputPath: string, profile: VitestProfile) =
|
|
|
750
757
|
*/
|
|
751
758
|
declare const runVitestProfile: (options: VitestProfileOptions, dependencies?: VitestProfileDependencies) => Promise<VitestProfile>;
|
|
752
759
|
//#endregion
|
|
760
|
+
//#region src/vitest-profile-reader.d.ts
|
|
761
|
+
/**
|
|
762
|
+
* Outcome of reading one candidate document. `unrecognized` carries the first
|
|
763
|
+
* reason the document failed — an analyzer reports it verbatim rather than
|
|
764
|
+
* treating an unreadable profile as an empty one.
|
|
765
|
+
*/
|
|
766
|
+
type VitestProfileReadResult = {
|
|
767
|
+
kind: "profile";
|
|
768
|
+
profile: VitestProfile;
|
|
769
|
+
} | {
|
|
770
|
+
kind: "unrecognized";
|
|
771
|
+
reason: string;
|
|
772
|
+
};
|
|
773
|
+
/**
|
|
774
|
+
* Check one parsed JSON document against the profile contract the writer
|
|
775
|
+
* emits. Returns the typed profile on success and the first mismatch reason
|
|
776
|
+
* otherwise — never a partially-usable value.
|
|
777
|
+
*/
|
|
778
|
+
declare const readVitestProfileDocument: (value: unknown) => VitestProfileReadResult;
|
|
779
|
+
//#endregion
|
|
753
780
|
//#region src/workflow-shell-lint.d.ts
|
|
754
781
|
/**
|
|
755
782
|
* Parse-check the shell embedded in generated workflow YAML (#376).
|
|
@@ -801,4 +828,4 @@ declare const assertWorkflowShellParses: (yaml: string, options: {
|
|
|
801
828
|
readonly source: string;
|
|
802
829
|
}) => void;
|
|
803
830
|
//#endregion
|
|
804
|
-
export { type BundleAlchemyEntryOptions, type CheckoutStepOptions, type ExecuteAlchemyEntryOptions, type ExecuteAlchemyEntryResult, FACTORY_PROOF_GATE_APP_ID, FACTORY_PROOF_GATE_CHECK_NAME, FACTORY_PROOF_GATE_GUARD, FACTORY_PROOF_GATE_IF, FACTORY_PROOF_GATE_MODE_OUTPUT, FACTORY_PROOF_GATE_OUTPUT, FACTORY_PROOF_GATE_REASONS, FACTORY_PROOF_GATE_REASON_OUTPUT, FACTORY_PROOF_GATE_SHELL, FACTORY_PROOF_GATE_STEP_ID, type FactoryProofGateOptions, type FactoryProofGateReason, type FactoryProofGateStep, type FactoryWorkflowArtifact, type FactoryWorkflowOptions, type FactoryWorkflowSetupOptions, GitHubApiError, type GithubAppCredentials, type GithubAppTokenOptions, type InstallStepOptions, type LocalPreviewStage, type LocalPreviewStageOptions, NODE_PNPM_ACTION_FAMILY_NODE24, type NodePnpmActionFamily, type ParseLocalPreviewStageExpected, type ParsedLocalPreviewStage, type PinnedAction, type ProofReuseCommand, type ProofReuseCoverageInput, type ProofReuseCoverageReport, type SetupNodeStepOptions, VITEST_PROFILE_SCHEMA_VERSION, VITEST_PROFILE_TOOL, type VitestJsonReport, type VitestProfile, type VitestProfileDependencies, type VitestProfileDurationSummary, type VitestProfileEnvironment, VitestProfileError, type VitestProfileOptions, type VitestProfileSample, type VitestProfileSampleExecution, type VitestTestStatus, type WorkflowShellParseFailure, type WorkflowStep, assertProofReuseCoverage, assertWorkflowShellParses, bundleAlchemyEntry, captureVitestProfileEnvironment, executeAlchemyEntry, factoryProofGateScript, factoryProofGateStep, factoryWorkflow, githubAppJwt, isLocalPreviewStage, localPreviewStage, mintInstallationToken, normalizeVitestProfileSample, parseLocalPreviewStage, proofReuseCoverage, proofReuseRequiredCommands, resolveProofReuseCommands, runVitestProfile, workflowRunBlocks, workflowShellParseFailures, writeVitestProfile };
|
|
831
|
+
export { type BundleAlchemyEntryOptions, type CheckoutStepOptions, type ExecuteAlchemyEntryOptions, type ExecuteAlchemyEntryResult, FACTORY_PROOF_GATE_APP_ID, FACTORY_PROOF_GATE_CHECK_NAME, FACTORY_PROOF_GATE_GUARD, FACTORY_PROOF_GATE_IF, FACTORY_PROOF_GATE_MODE_OUTPUT, FACTORY_PROOF_GATE_OUTPUT, FACTORY_PROOF_GATE_REASONS, FACTORY_PROOF_GATE_REASON_OUTPUT, FACTORY_PROOF_GATE_SHELL, FACTORY_PROOF_GATE_STEP_ID, FACTORY_PROOF_GATE_STEP_NAME, type FactoryProofGateOptions, type FactoryProofGateReason, type FactoryProofGateStep, type FactoryWorkflowArtifact, type FactoryWorkflowOptions, type FactoryWorkflowSetupOptions, GitHubApiError, type GithubAppCredentials, type GithubAppTokenOptions, type InstallStepOptions, type LocalPreviewStage, type LocalPreviewStageOptions, NODE_PNPM_ACTION_FAMILY_NODE24, type NodePnpmActionFamily, type ParseLocalPreviewStageExpected, type ParsedLocalPreviewStage, type PinnedAction, type ProofReuseCommand, type ProofReuseCoverageInput, type ProofReuseCoverageReport, type SetupNodeStepOptions, VITEST_PROFILE_SCHEMA_VERSION, VITEST_PROFILE_TOOL, type VitestJsonReport, type VitestProfile, type VitestProfileDependencies, type VitestProfileDurationSummary, type VitestProfileEnvironment, VitestProfileError, type VitestProfileOptions, type VitestProfileReadResult, type VitestProfileSample, type VitestProfileSampleExecution, type VitestTestStatus, type WorkflowShellParseFailure, type WorkflowStep, assertProofReuseCoverage, assertWorkflowShellParses, bundleAlchemyEntry, captureVitestProfileEnvironment, executeAlchemyEntry, factoryProofGateScript, factoryProofGateStep, factoryWorkflow, githubAppJwt, isLocalPreviewStage, localPreviewStage, mintInstallationToken, normalizeVitestProfileSample, parseLocalPreviewStage, proofReuseCoverage, proofReuseRequiredCommands, readVitestProfileDocument, resolveProofReuseCommands, runVitestProfile, workflowRunBlocks, workflowShellParseFailures, writeVitestProfile };
|
package/dist/index.js
CHANGED
|
@@ -435,6 +435,13 @@ const FACTORY_PROOF_GATE_APP_ID = "4314840";
|
|
|
435
435
|
/** Step id the guard condition refers to. */
|
|
436
436
|
const FACTORY_PROOF_GATE_STEP_ID = "factory-proof";
|
|
437
437
|
/**
|
|
438
|
+
* Human-visible name of the gate step as the Actions jobs API serves it. A
|
|
439
|
+
* read-only run analyzer (`psf ci:analyze`, #647) matches on this name to
|
|
440
|
+
* classify a run as proof-reuse versus full fallback, so it is exported from
|
|
441
|
+
* exactly the module that writes it — matching on a re-typed copy would drift.
|
|
442
|
+
*/
|
|
443
|
+
const FACTORY_PROOF_GATE_STEP_NAME = "Check for factory proof of this head";
|
|
444
|
+
/**
|
|
438
445
|
* The shell the gate runs under, and it is a correctness requirement rather
|
|
439
446
|
* than a preference.
|
|
440
447
|
*
|
|
@@ -906,7 +913,7 @@ const factoryProofGateStep = (options) => Object.freeze({
|
|
|
906
913
|
}),
|
|
907
914
|
id: FACTORY_PROOF_GATE_STEP_ID,
|
|
908
915
|
if: FACTORY_PROOF_GATE_IF,
|
|
909
|
-
name:
|
|
916
|
+
name: FACTORY_PROOF_GATE_STEP_NAME,
|
|
910
917
|
run: factoryProofGateScript(options),
|
|
911
918
|
shell: FACTORY_PROOF_GATE_SHELL
|
|
912
919
|
});
|
|
@@ -1261,6 +1268,120 @@ const runVitestProfile = async (options, dependencies = {}) => {
|
|
|
1261
1268
|
return profile;
|
|
1262
1269
|
};
|
|
1263
1270
|
//#endregion
|
|
1271
|
+
//#region src/vitest-profile-reader.ts
|
|
1272
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1273
|
+
const isFiniteNumber = (value) => typeof value === "number" && Number.isFinite(value);
|
|
1274
|
+
const durationSummaryFailure = (value, where) => {
|
|
1275
|
+
if (!isRecord(value)) return `${where} is not an object`;
|
|
1276
|
+
const missing = [
|
|
1277
|
+
"maximum",
|
|
1278
|
+
"mean",
|
|
1279
|
+
"median",
|
|
1280
|
+
"minimum"
|
|
1281
|
+
].find((field) => !isFiniteNumber(value[field]));
|
|
1282
|
+
return missing === void 0 ? void 0 : `${where}.${missing} is not a finite number`;
|
|
1283
|
+
};
|
|
1284
|
+
const environmentFailure = (value) => {
|
|
1285
|
+
if (!isRecord(value)) return "environment is not an object";
|
|
1286
|
+
if (!(typeof value.cpuModel === "string" || value.cpuModel === null)) return "environment.cpuModel is neither a string nor null";
|
|
1287
|
+
if (!isFiniteNumber(value.cpuCount)) return "environment.cpuCount is not a finite number";
|
|
1288
|
+
};
|
|
1289
|
+
/** The count fields the writer emits; every one must be a finite number. */
|
|
1290
|
+
const COUNT_FIELDS = [
|
|
1291
|
+
"failed",
|
|
1292
|
+
"passed",
|
|
1293
|
+
"pending",
|
|
1294
|
+
"suites",
|
|
1295
|
+
"tests",
|
|
1296
|
+
"todo"
|
|
1297
|
+
];
|
|
1298
|
+
const runFailure = (value, index) => {
|
|
1299
|
+
if (!isRecord(value)) return `runs[${index}] is not an object`;
|
|
1300
|
+
if (!isFiniteNumber(value.durationMs)) return `runs[${index}].durationMs is not a finite number`;
|
|
1301
|
+
if (value.counts === null) return;
|
|
1302
|
+
if (!isRecord(value.counts)) return `runs[${index}].counts is neither an object nor null`;
|
|
1303
|
+
const { counts } = value;
|
|
1304
|
+
const missing = COUNT_FIELDS.find((field) => !isFiniteNumber(counts[field]));
|
|
1305
|
+
return missing === void 0 ? void 0 : `runs[${index}].counts.${missing} is not a finite number`;
|
|
1306
|
+
};
|
|
1307
|
+
const slowEntryFailure = (value, where, labelField) => {
|
|
1308
|
+
if (!isRecord(value)) return `${where} is not an object`;
|
|
1309
|
+
if (typeof value[labelField] !== "string") return `${where}.${labelField} is not a string`;
|
|
1310
|
+
if (labelField === "name" && typeof value.file !== "string") return `${where}.file is not a string`;
|
|
1311
|
+
return durationSummaryFailure(value.durationMs, `${where}.durationMs`);
|
|
1312
|
+
};
|
|
1313
|
+
const summaryFailure = (value) => {
|
|
1314
|
+
if (!isRecord(value)) return "summary is not an object";
|
|
1315
|
+
const durations = durationSummaryFailure(value.durationMs, "summary.durationMs");
|
|
1316
|
+
if (durations !== void 0) return durations;
|
|
1317
|
+
if (!Array.isArray(value.slowFiles)) return "summary.slowFiles is not an array";
|
|
1318
|
+
for (const [index, entry] of value.slowFiles.entries()) {
|
|
1319
|
+
const failure = slowEntryFailure(entry, `summary.slowFiles[${index}]`, "path");
|
|
1320
|
+
if (failure !== void 0) return failure;
|
|
1321
|
+
}
|
|
1322
|
+
if (!Array.isArray(value.slowTests)) return "summary.slowTests is not an array";
|
|
1323
|
+
for (const [index, entry] of value.slowTests.entries()) {
|
|
1324
|
+
const failure = slowEntryFailure(entry, `summary.slowTests[${index}]`, "name");
|
|
1325
|
+
if (failure !== void 0) return failure;
|
|
1326
|
+
}
|
|
1327
|
+
};
|
|
1328
|
+
/**
|
|
1329
|
+
* Check one parsed JSON document against the profile contract the writer
|
|
1330
|
+
* emits. Returns the typed profile on success and the first mismatch reason
|
|
1331
|
+
* otherwise — never a partially-usable value.
|
|
1332
|
+
*/
|
|
1333
|
+
const readVitestProfileDocument = (value) => {
|
|
1334
|
+
if (!isRecord(value)) return {
|
|
1335
|
+
kind: "unrecognized",
|
|
1336
|
+
reason: "the document is not an object"
|
|
1337
|
+
};
|
|
1338
|
+
if (value.tool !== "factory-ci-vitest-profile") return {
|
|
1339
|
+
kind: "unrecognized",
|
|
1340
|
+
reason: `tool is ${JSON.stringify(value.tool)} rather than "${VITEST_PROFILE_TOOL}"`
|
|
1341
|
+
};
|
|
1342
|
+
if (value.schemaVersion !== 1) return {
|
|
1343
|
+
kind: "unrecognized",
|
|
1344
|
+
reason: `schemaVersion is ${JSON.stringify(value.schemaVersion)} rather than 1`
|
|
1345
|
+
};
|
|
1346
|
+
if (!(Array.isArray(value.command) && value.command.every((part) => typeof part === "string"))) return {
|
|
1347
|
+
kind: "unrecognized",
|
|
1348
|
+
reason: "command is not an array of strings"
|
|
1349
|
+
};
|
|
1350
|
+
const environment = environmentFailure(value.environment);
|
|
1351
|
+
if (environment !== void 0) return {
|
|
1352
|
+
kind: "unrecognized",
|
|
1353
|
+
reason: environment
|
|
1354
|
+
};
|
|
1355
|
+
if (!isRecord(value.options)) return {
|
|
1356
|
+
kind: "unrecognized",
|
|
1357
|
+
reason: "options is not an object"
|
|
1358
|
+
};
|
|
1359
|
+
if (!isFiniteNumber(value.options.maxWorkers)) return {
|
|
1360
|
+
kind: "unrecognized",
|
|
1361
|
+
reason: "options.maxWorkers is not a finite number"
|
|
1362
|
+
};
|
|
1363
|
+
if (!Array.isArray(value.runs)) return {
|
|
1364
|
+
kind: "unrecognized",
|
|
1365
|
+
reason: "runs is not an array"
|
|
1366
|
+
};
|
|
1367
|
+
for (const [index, run] of value.runs.entries()) {
|
|
1368
|
+
const failure = runFailure(run, index);
|
|
1369
|
+
if (failure !== void 0) return {
|
|
1370
|
+
kind: "unrecognized",
|
|
1371
|
+
reason: failure
|
|
1372
|
+
};
|
|
1373
|
+
}
|
|
1374
|
+
const summary = summaryFailure(value.summary);
|
|
1375
|
+
if (summary !== void 0) return {
|
|
1376
|
+
kind: "unrecognized",
|
|
1377
|
+
reason: summary
|
|
1378
|
+
};
|
|
1379
|
+
return {
|
|
1380
|
+
kind: "profile",
|
|
1381
|
+
profile: value
|
|
1382
|
+
};
|
|
1383
|
+
};
|
|
1384
|
+
//#endregion
|
|
1264
1385
|
//#region src/workflow-shell-lint.ts
|
|
1265
1386
|
const RUN_KEY = /^(?<indent>\s*)(?:-\s+)?run:(?<inline>.*)$/u;
|
|
1266
1387
|
/**
|
|
@@ -1525,4 +1646,4 @@ const assertWorkflowShellParses = (yaml, options) => {
|
|
|
1525
1646
|
throw new Error(`${options.source} emits shell bash cannot parse; the runner would treat it as a no-op:\n${detail}`);
|
|
1526
1647
|
};
|
|
1527
1648
|
//#endregion
|
|
1528
|
-
export { FACTORY_PROOF_GATE_APP_ID, FACTORY_PROOF_GATE_CHECK_NAME, FACTORY_PROOF_GATE_GUARD, FACTORY_PROOF_GATE_IF, FACTORY_PROOF_GATE_MODE_OUTPUT, FACTORY_PROOF_GATE_OUTPUT, FACTORY_PROOF_GATE_REASONS, FACTORY_PROOF_GATE_REASON_OUTPUT, FACTORY_PROOF_GATE_SHELL, FACTORY_PROOF_GATE_STEP_ID, GitHubApiError, NODE_PNPM_ACTION_FAMILY_NODE24, VITEST_PROFILE_SCHEMA_VERSION, VITEST_PROFILE_TOOL, VitestProfileError, assertProofReuseCoverage, assertWorkflowShellParses, bundleAlchemyEntry, captureVitestProfileEnvironment, executeAlchemyEntry, factoryProofGateScript, factoryProofGateStep, factoryWorkflow, githubAppJwt, isLocalPreviewStage, localPreviewStage, mintInstallationToken, normalizeVitestProfileSample, parseLocalPreviewStage, proofReuseCoverage, proofReuseRequiredCommands, resolveProofReuseCommands, runVitestProfile, workflowRunBlocks, workflowShellParseFailures, writeVitestProfile };
|
|
1649
|
+
export { FACTORY_PROOF_GATE_APP_ID, FACTORY_PROOF_GATE_CHECK_NAME, FACTORY_PROOF_GATE_GUARD, FACTORY_PROOF_GATE_IF, FACTORY_PROOF_GATE_MODE_OUTPUT, FACTORY_PROOF_GATE_OUTPUT, FACTORY_PROOF_GATE_REASONS, FACTORY_PROOF_GATE_REASON_OUTPUT, FACTORY_PROOF_GATE_SHELL, FACTORY_PROOF_GATE_STEP_ID, FACTORY_PROOF_GATE_STEP_NAME, GitHubApiError, NODE_PNPM_ACTION_FAMILY_NODE24, VITEST_PROFILE_SCHEMA_VERSION, VITEST_PROFILE_TOOL, VitestProfileError, assertProofReuseCoverage, assertWorkflowShellParses, bundleAlchemyEntry, captureVitestProfileEnvironment, executeAlchemyEntry, factoryProofGateScript, factoryProofGateStep, factoryWorkflow, githubAppJwt, isLocalPreviewStage, localPreviewStage, mintInstallationToken, normalizeVitestProfileSample, parseLocalPreviewStage, proofReuseCoverage, proofReuseRequiredCommands, readVitestProfileDocument, resolveProofReuseCommands, runVitestProfile, workflowRunBlocks, workflowShellParseFailures, writeVitestProfile };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@patronage/factory-ci",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.8",
|
|
4
4
|
"description": "Deep CI and deploy building blocks for Patronage factory projects: workflow source artifacts, hosted diff classification, Alchemy entry execution, and disposable-stage semantics",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"alchemy",
|
package/src/index.ts
CHANGED
|
@@ -60,6 +60,7 @@ export {
|
|
|
60
60
|
FACTORY_PROOF_GATE_REASONS,
|
|
61
61
|
FACTORY_PROOF_GATE_SHELL,
|
|
62
62
|
FACTORY_PROOF_GATE_STEP_ID,
|
|
63
|
+
FACTORY_PROOF_GATE_STEP_NAME,
|
|
63
64
|
type FactoryProofGateOptions,
|
|
64
65
|
type FactoryProofGateReason,
|
|
65
66
|
factoryProofGateScript,
|
|
@@ -90,6 +91,10 @@ export {
|
|
|
90
91
|
type VitestTestStatus,
|
|
91
92
|
writeVitestProfile,
|
|
92
93
|
} from "./vitest-profile.ts";
|
|
94
|
+
export {
|
|
95
|
+
readVitestProfileDocument,
|
|
96
|
+
type VitestProfileReadResult,
|
|
97
|
+
} from "./vitest-profile-reader.ts";
|
|
93
98
|
export {
|
|
94
99
|
assertWorkflowShellParses,
|
|
95
100
|
workflowRunBlocks,
|
package/src/proof-reuse-gate.ts
CHANGED
|
@@ -57,6 +57,15 @@ export const FACTORY_PROOF_GATE_APP_ID = "4314840";
|
|
|
57
57
|
/** Step id the guard condition refers to. */
|
|
58
58
|
export const FACTORY_PROOF_GATE_STEP_ID = "factory-proof";
|
|
59
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Human-visible name of the gate step as the Actions jobs API serves it. A
|
|
62
|
+
* read-only run analyzer (`psf ci:analyze`, #647) matches on this name to
|
|
63
|
+
* classify a run as proof-reuse versus full fallback, so it is exported from
|
|
64
|
+
* exactly the module that writes it — matching on a re-typed copy would drift.
|
|
65
|
+
*/
|
|
66
|
+
export const FACTORY_PROOF_GATE_STEP_NAME =
|
|
67
|
+
"Check for factory proof of this head";
|
|
68
|
+
|
|
60
69
|
/**
|
|
61
70
|
* The shell the gate runs under, and it is a correctness requirement rather
|
|
62
71
|
* than a preference.
|
|
@@ -619,7 +628,7 @@ export const factoryProofGateStep = (
|
|
|
619
628
|
}),
|
|
620
629
|
id: FACTORY_PROOF_GATE_STEP_ID,
|
|
621
630
|
if: FACTORY_PROOF_GATE_IF,
|
|
622
|
-
name:
|
|
631
|
+
name: FACTORY_PROOF_GATE_STEP_NAME,
|
|
623
632
|
run: factoryProofGateScript(options),
|
|
624
633
|
// Never omit: the runner's default `run:` shell supplies `-e`, which
|
|
625
634
|
// voids the fail-open design. See FACTORY_PROOF_GATE_SHELL.
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read back a Vitest profile artifact (#647).
|
|
3
|
+
*
|
|
4
|
+
* `vitest-profile.ts` owns the writer; this module owns the matching
|
|
5
|
+
* schema-checked reader, so a consumer analyzing profile artifacts collected
|
|
6
|
+
* from CI (for example `psf ci:analyze`) never re-types the document shape.
|
|
7
|
+
* The reader is deliberately strict about the fields an analysis is built on —
|
|
8
|
+
* tool discriminator, schema version, environment capture, per-run durations,
|
|
9
|
+
* and the duration summary — and deliberately silent about everything else, so
|
|
10
|
+
* additive writer changes never invalidate old artifacts.
|
|
11
|
+
*
|
|
12
|
+
* Pure: no I/O, no dependencies. The caller decides where the bytes come from.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type {
|
|
16
|
+
VitestProfile,
|
|
17
|
+
VitestProfileDurationSummary,
|
|
18
|
+
} from "./vitest-profile.ts";
|
|
19
|
+
import {
|
|
20
|
+
VITEST_PROFILE_SCHEMA_VERSION,
|
|
21
|
+
VITEST_PROFILE_TOOL,
|
|
22
|
+
} from "./vitest-profile.ts";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Outcome of reading one candidate document. `unrecognized` carries the first
|
|
26
|
+
* reason the document failed — an analyzer reports it verbatim rather than
|
|
27
|
+
* treating an unreadable profile as an empty one.
|
|
28
|
+
*/
|
|
29
|
+
export type VitestProfileReadResult =
|
|
30
|
+
| { kind: "profile"; profile: VitestProfile }
|
|
31
|
+
| { kind: "unrecognized"; reason: string };
|
|
32
|
+
|
|
33
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
34
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
35
|
+
|
|
36
|
+
const isFiniteNumber = (value: unknown): value is number =>
|
|
37
|
+
typeof value === "number" && Number.isFinite(value);
|
|
38
|
+
|
|
39
|
+
const durationSummaryFailure = (
|
|
40
|
+
value: unknown,
|
|
41
|
+
where: string
|
|
42
|
+
): string | undefined => {
|
|
43
|
+
if (!isRecord(value)) {
|
|
44
|
+
return `${where} is not an object`;
|
|
45
|
+
}
|
|
46
|
+
const fields: (keyof VitestProfileDurationSummary)[] = [
|
|
47
|
+
"maximum",
|
|
48
|
+
"mean",
|
|
49
|
+
"median",
|
|
50
|
+
"minimum",
|
|
51
|
+
];
|
|
52
|
+
const missing = fields.find((field) => !isFiniteNumber(value[field]));
|
|
53
|
+
return missing === undefined
|
|
54
|
+
? undefined
|
|
55
|
+
: `${where}.${missing} is not a finite number`;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const environmentFailure = (value: unknown): string | undefined => {
|
|
59
|
+
if (!isRecord(value)) {
|
|
60
|
+
return "environment is not an object";
|
|
61
|
+
}
|
|
62
|
+
if (!(typeof value.cpuModel === "string" || value.cpuModel === null)) {
|
|
63
|
+
return "environment.cpuModel is neither a string nor null";
|
|
64
|
+
}
|
|
65
|
+
if (!isFiniteNumber(value.cpuCount)) {
|
|
66
|
+
return "environment.cpuCount is not a finite number";
|
|
67
|
+
}
|
|
68
|
+
return undefined;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/** The count fields the writer emits; every one must be a finite number. */
|
|
72
|
+
const COUNT_FIELDS = [
|
|
73
|
+
"failed",
|
|
74
|
+
"passed",
|
|
75
|
+
"pending",
|
|
76
|
+
"suites",
|
|
77
|
+
"tests",
|
|
78
|
+
"todo",
|
|
79
|
+
] as const;
|
|
80
|
+
|
|
81
|
+
const runFailure = (value: unknown, index: number): string | undefined => {
|
|
82
|
+
if (!isRecord(value)) {
|
|
83
|
+
return `runs[${index}] is not an object`;
|
|
84
|
+
}
|
|
85
|
+
if (!isFiniteNumber(value.durationMs)) {
|
|
86
|
+
return `runs[${index}].durationMs is not a finite number`;
|
|
87
|
+
}
|
|
88
|
+
if (value.counts === null) {
|
|
89
|
+
return undefined;
|
|
90
|
+
}
|
|
91
|
+
if (!isRecord(value.counts)) {
|
|
92
|
+
return `runs[${index}].counts is neither an object nor null`;
|
|
93
|
+
}
|
|
94
|
+
const { counts } = value;
|
|
95
|
+
const missing = COUNT_FIELDS.find((field) => !isFiniteNumber(counts[field]));
|
|
96
|
+
return missing === undefined
|
|
97
|
+
? undefined
|
|
98
|
+
: `runs[${index}].counts.${missing} is not a finite number`;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const slowEntryFailure = (
|
|
102
|
+
value: unknown,
|
|
103
|
+
where: string,
|
|
104
|
+
labelField: "name" | "path"
|
|
105
|
+
): string | undefined => {
|
|
106
|
+
if (!isRecord(value)) {
|
|
107
|
+
return `${where} is not an object`;
|
|
108
|
+
}
|
|
109
|
+
if (typeof value[labelField] !== "string") {
|
|
110
|
+
return `${where}.${labelField} is not a string`;
|
|
111
|
+
}
|
|
112
|
+
if (labelField === "name" && typeof value.file !== "string") {
|
|
113
|
+
return `${where}.file is not a string`;
|
|
114
|
+
}
|
|
115
|
+
return durationSummaryFailure(value.durationMs, `${where}.durationMs`);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const summaryFailure = (value: unknown): string | undefined => {
|
|
119
|
+
if (!isRecord(value)) {
|
|
120
|
+
return "summary is not an object";
|
|
121
|
+
}
|
|
122
|
+
const durations = durationSummaryFailure(
|
|
123
|
+
value.durationMs,
|
|
124
|
+
"summary.durationMs"
|
|
125
|
+
);
|
|
126
|
+
if (durations !== undefined) {
|
|
127
|
+
return durations;
|
|
128
|
+
}
|
|
129
|
+
if (!Array.isArray(value.slowFiles)) {
|
|
130
|
+
return "summary.slowFiles is not an array";
|
|
131
|
+
}
|
|
132
|
+
for (const [index, entry] of value.slowFiles.entries()) {
|
|
133
|
+
const failure = slowEntryFailure(
|
|
134
|
+
entry,
|
|
135
|
+
`summary.slowFiles[${index}]`,
|
|
136
|
+
"path"
|
|
137
|
+
);
|
|
138
|
+
if (failure !== undefined) {
|
|
139
|
+
return failure;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (!Array.isArray(value.slowTests)) {
|
|
143
|
+
return "summary.slowTests is not an array";
|
|
144
|
+
}
|
|
145
|
+
for (const [index, entry] of value.slowTests.entries()) {
|
|
146
|
+
const failure = slowEntryFailure(
|
|
147
|
+
entry,
|
|
148
|
+
`summary.slowTests[${index}]`,
|
|
149
|
+
"name"
|
|
150
|
+
);
|
|
151
|
+
if (failure !== undefined) {
|
|
152
|
+
return failure;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return undefined;
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Check one parsed JSON document against the profile contract the writer
|
|
160
|
+
* emits. Returns the typed profile on success and the first mismatch reason
|
|
161
|
+
* otherwise — never a partially-usable value.
|
|
162
|
+
*/
|
|
163
|
+
export const readVitestProfileDocument = (
|
|
164
|
+
value: unknown
|
|
165
|
+
): VitestProfileReadResult => {
|
|
166
|
+
if (!isRecord(value)) {
|
|
167
|
+
return { kind: "unrecognized", reason: "the document is not an object" };
|
|
168
|
+
}
|
|
169
|
+
if (value.tool !== VITEST_PROFILE_TOOL) {
|
|
170
|
+
return {
|
|
171
|
+
kind: "unrecognized",
|
|
172
|
+
reason: `tool is ${JSON.stringify(value.tool)} rather than "${VITEST_PROFILE_TOOL}"`,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
if (value.schemaVersion !== VITEST_PROFILE_SCHEMA_VERSION) {
|
|
176
|
+
return {
|
|
177
|
+
kind: "unrecognized",
|
|
178
|
+
reason: `schemaVersion is ${JSON.stringify(value.schemaVersion)} rather than ${VITEST_PROFILE_SCHEMA_VERSION}`,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
if (
|
|
182
|
+
!(
|
|
183
|
+
Array.isArray(value.command) &&
|
|
184
|
+
value.command.every((part) => typeof part === "string")
|
|
185
|
+
)
|
|
186
|
+
) {
|
|
187
|
+
return {
|
|
188
|
+
kind: "unrecognized",
|
|
189
|
+
reason: "command is not an array of strings",
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
const environment = environmentFailure(value.environment);
|
|
193
|
+
if (environment !== undefined) {
|
|
194
|
+
return { kind: "unrecognized", reason: environment };
|
|
195
|
+
}
|
|
196
|
+
if (!isRecord(value.options)) {
|
|
197
|
+
return { kind: "unrecognized", reason: "options is not an object" };
|
|
198
|
+
}
|
|
199
|
+
if (!isFiniteNumber(value.options.maxWorkers)) {
|
|
200
|
+
return {
|
|
201
|
+
kind: "unrecognized",
|
|
202
|
+
reason: "options.maxWorkers is not a finite number",
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
if (!Array.isArray(value.runs)) {
|
|
206
|
+
return { kind: "unrecognized", reason: "runs is not an array" };
|
|
207
|
+
}
|
|
208
|
+
for (const [index, run] of value.runs.entries()) {
|
|
209
|
+
const failure = runFailure(run, index);
|
|
210
|
+
if (failure !== undefined) {
|
|
211
|
+
return { kind: "unrecognized", reason: failure };
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const summary = summaryFailure(value.summary);
|
|
215
|
+
if (summary !== undefined) {
|
|
216
|
+
return { kind: "unrecognized", reason: summary };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return { kind: "profile", profile: value as unknown as VitestProfile };
|
|
220
|
+
};
|