Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b4b956d51 | ||
|
|
a2a97ebcc7 |
@@ -1,5 +1,25 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"releaseId": "local-release",
|
||||
"environment": "replace-with-production",
|
||||
"source": {
|
||||
"system": "",
|
||||
"exportId": ""
|
||||
},
|
||||
"privacy": {
|
||||
"approved": false,
|
||||
"approvalRef": ""
|
||||
},
|
||||
"window": {
|
||||
"start": "2026-06-01T00:00:00Z",
|
||||
"end": "2026-06-29T00:00:00Z"
|
||||
},
|
||||
"thresholdDecision": {
|
||||
"status": "pending",
|
||||
"minimumEligibleSamples": null,
|
||||
"owner": "",
|
||||
"reviewedAt": "",
|
||||
"evidenceRef": ""
|
||||
},
|
||||
"samples": []
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"window",
|
||||
"context",
|
||||
"metrics",
|
||||
"thresholds",
|
||||
"eligibility",
|
||||
"status",
|
||||
"passed"
|
||||
@@ -18,12 +19,55 @@
|
||||
"window": { "type": "object", "required": ["days", "start", "end"] },
|
||||
"context": {
|
||||
"type": "object",
|
||||
"required": ["source", "network", "routeAggregation", "releaseId"]
|
||||
"required": [
|
||||
"source",
|
||||
"sourceSystem",
|
||||
"exportId",
|
||||
"network",
|
||||
"routeAggregation",
|
||||
"releaseId",
|
||||
"privacyApprovalRef",
|
||||
"thresholdDecisionRef",
|
||||
"validationFailures"
|
||||
],
|
||||
"properties": {
|
||||
"source": { "type": "string" },
|
||||
"sourceSystem": { "type": ["string", "null"] },
|
||||
"exportId": { "type": ["string", "null"] },
|
||||
"network": { "const": "production-real-user" },
|
||||
"routeAggregation": { "const": "route-id-only" },
|
||||
"releaseId": { "type": ["string", "null"] },
|
||||
"privacyApprovalRef": { "type": ["string", "null"] },
|
||||
"thresholdDecisionRef": { "type": ["string", "null"] },
|
||||
"validationFailures": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"thresholds": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"p75LcpMs",
|
||||
"p75Cls",
|
||||
"p75InpMs",
|
||||
"minimumEligibleSamples"
|
||||
]
|
||||
},
|
||||
"metrics": {
|
||||
"type": "object",
|
||||
"required": ["p75LcpMs", "p75Cls", "p75InpMs"]
|
||||
},
|
||||
"metrics": { "type": "object" },
|
||||
"eligibility": {
|
||||
"type": "object",
|
||||
"required": ["consentRequired", "eligibleSamples", "minimumEligibleSamples"]
|
||||
"required": [
|
||||
"consentRequired",
|
||||
"totalSamples",
|
||||
"eligibleSamples",
|
||||
"minimumEligibleSamples",
|
||||
"routeSamples"
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"enum": ["PASS", "FAIL_THRESHOLD", "FAIL_UNVERIFIED"]
|
||||
|
||||
@@ -13,5 +13,10 @@ Performance evidence is deliberately split by measurement context:
|
||||
The field minimum eligible-sample threshold is intentionally unresolved until
|
||||
a privacy-approved telemetry baseline exists. Therefore the field command
|
||||
fails closed with `FAIL_UNVERIFIED` when run against the example input. Provide
|
||||
`FIELD_WEB_VITALS_INPUT` and a reviewed `MIN_ELIGIBLE_SAMPLES` only after that
|
||||
decision is recorded.
|
||||
`FIELD_WEB_VITALS_INPUT` and `MIN_ELIGIBLE_SAMPLES` only after that decision is
|
||||
recorded. The external input must identify a production release and an exact
|
||||
28-day export window, name the source/export, carry privacy-approval and
|
||||
threshold-decision references, and contain only non-negative route-ID samples.
|
||||
The environment threshold must be a positive integer equal to the approved
|
||||
decision embedded in the input. Invalid metadata fails as `FAIL_UNVERIFIED`;
|
||||
the example can never serve as production evidence.
|
||||
|
||||
+22
-15
@@ -1,6 +1,7 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
|
||||
import { evaluateBundleBudget } from "../src/application/policies/performance-budgets.js";
|
||||
import { classifyViteJavascript } from "./lib/classify-vite-bundle.mjs";
|
||||
|
||||
const report =
|
||||
/** @type {{
|
||||
@@ -10,7 +11,7 @@ const report =
|
||||
JSON.parse(await readFile("artifacts/performance/bundle.json", "utf8"))
|
||||
);
|
||||
const viteManifest =
|
||||
/** @type {Record<string, { file: string, isEntry?: boolean }>} */ (
|
||||
/** @type {Record<string, { file: string, isEntry?: boolean, imports?: string[] }>} */ (
|
||||
JSON.parse(await readFile("dist/.vite/manifest.json", "utf8"))
|
||||
);
|
||||
const budgets =
|
||||
@@ -21,24 +22,19 @@ const budgets =
|
||||
const outputByPath = new Map(
|
||||
report.outputs.map((output) => [output.path.replace(/^dist\//, ""), output]),
|
||||
);
|
||||
const initialFiles = new Set(
|
||||
Object.values(viteManifest)
|
||||
.filter((entry) => entry.isEntry)
|
||||
.map((entry) => entry.file),
|
||||
);
|
||||
const lazyFiles = new Set(
|
||||
Object.values(viteManifest)
|
||||
.filter((entry) => !entry.isEntry && entry.file.endsWith(".js"))
|
||||
.map((entry) => entry.file),
|
||||
);
|
||||
const initialJsGzipBytes = [...initialFiles].reduce(
|
||||
const classification = classifyViteJavascript(viteManifest);
|
||||
const initialJsGzipBytes = classification.initialFiles.reduce(
|
||||
(total, file) => total + (outputByPath.get(file)?.gzipBytes ?? 0),
|
||||
0,
|
||||
);
|
||||
const lazyChunks = [...lazyFiles].map((file) => ({
|
||||
const lazyChunks = classification.lazyFiles.map((file) => ({
|
||||
path: file,
|
||||
gzipBytes: outputByPath.get(file)?.gzipBytes ?? 0,
|
||||
}));
|
||||
const missingOutputs = [
|
||||
...classification.initialFiles,
|
||||
...classification.lazyFiles,
|
||||
].filter((file) => !outputByPath.has(file));
|
||||
const measurements = { initialJsGzipBytes, lazyChunks };
|
||||
const result = evaluateBundleBudget(measurements, budgets);
|
||||
const fixtures = [
|
||||
@@ -70,10 +66,16 @@ const fixtures = [
|
||||
).passed,
|
||||
},
|
||||
];
|
||||
const passed = result.passed && fixtures.every((fixture) => fixture.passed);
|
||||
const passed =
|
||||
result.passed &&
|
||||
fixtures.every((fixture) => fixture.passed) &&
|
||||
classification.missingImports.length === 0 &&
|
||||
missingOutputs.length === 0;
|
||||
const completedReport = {
|
||||
...report,
|
||||
measurements,
|
||||
classification,
|
||||
missingOutputs,
|
||||
thresholds: budgets,
|
||||
results: result,
|
||||
fixtures,
|
||||
@@ -85,7 +87,12 @@ await writeFile(
|
||||
`${JSON.stringify(completedReport, null, 2)}\n`,
|
||||
);
|
||||
if (!passed) {
|
||||
process.stderr.write("Bundle budget exceeded.\n");
|
||||
process.stderr.write(
|
||||
`Bundle budget or manifest integrity failed: ${[
|
||||
...classification.missingImports,
|
||||
...missingOutputs,
|
||||
].join(", ")}\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
process.stdout.write(
|
||||
|
||||
@@ -4,23 +4,19 @@ import {
|
||||
evaluateFieldBudget,
|
||||
percentile75,
|
||||
} from "../src/application/policies/performance-budgets.js";
|
||||
import { validateFieldEvidenceInput } from "./lib/field-vitals-evidence.mjs";
|
||||
|
||||
const inputPath =
|
||||
process.env.FIELD_WEB_VITALS_INPUT ??
|
||||
"config/performance/field-input.example.json";
|
||||
const input =
|
||||
/** @type {{
|
||||
* releaseId: string,
|
||||
* samples: Array<{
|
||||
* timestamp: string,
|
||||
* consent: boolean,
|
||||
* releaseId: string,
|
||||
* routeId: string,
|
||||
* lcpMs: number,
|
||||
* cls: number,
|
||||
* inpMs: number
|
||||
* }>
|
||||
* }} */ (JSON.parse(await readFile(inputPath, "utf8")));
|
||||
const rawInput = JSON.parse(await readFile(inputPath, "utf8"));
|
||||
const now = new Date();
|
||||
const validation = validateFieldEvidenceInput(
|
||||
rawInput,
|
||||
process.env.MIN_ELIGIBLE_SAMPLES,
|
||||
now,
|
||||
);
|
||||
const input = validation.data;
|
||||
const configured =
|
||||
/** @type {{
|
||||
* p75LcpMs: number,
|
||||
@@ -30,17 +26,17 @@ const configured =
|
||||
* }} */ (
|
||||
JSON.parse(await readFile("config/performance/budgets.json", "utf8")).field
|
||||
);
|
||||
const minimumEligibleSamples = process.env.MIN_ELIGIBLE_SAMPLES
|
||||
? Number(process.env.MIN_ELIGIBLE_SAMPLES)
|
||||
: configured.minimumEligibleSamples;
|
||||
const end = new Date();
|
||||
const start = new Date(end);
|
||||
start.setUTCDate(start.getUTCDate() - 28);
|
||||
const eligible = input.samples.filter((sample) => {
|
||||
const minimumEligibleSamples = validation.minimumEligibleSamples;
|
||||
const fallbackEnd = now;
|
||||
const fallbackStart = new Date(fallbackEnd);
|
||||
fallbackStart.setUTCDate(fallbackStart.getUTCDate() - 28);
|
||||
const start = input ? new Date(input.window.start) : fallbackStart;
|
||||
const end = input ? new Date(input.window.end) : fallbackEnd;
|
||||
const eligible = (input?.samples ?? []).filter((sample) => {
|
||||
const timestamp = new Date(sample.timestamp);
|
||||
return (
|
||||
sample.consent === true &&
|
||||
sample.releaseId === input.releaseId &&
|
||||
sample.releaseId === input?.releaseId &&
|
||||
timestamp >= start &&
|
||||
timestamp <= end
|
||||
);
|
||||
@@ -55,6 +51,8 @@ const result = evaluateFieldBudget(
|
||||
{ metrics, eligibleSamples: eligible.length },
|
||||
thresholds,
|
||||
);
|
||||
const passed = validation.passed && result.passed;
|
||||
const status = validation.passed ? result.status : "FAIL_UNVERIFIED";
|
||||
const routeSamples = Object.fromEntries(
|
||||
Object.entries(
|
||||
eligible.reduce(
|
||||
@@ -68,24 +66,30 @@ const routeSamples = Object.fromEntries(
|
||||
);
|
||||
const report = {
|
||||
schemaVersion: 1,
|
||||
generatedAt: end.toISOString(),
|
||||
generatedAt: now.toISOString(),
|
||||
window: { days: 28, start: start.toISOString(), end: end.toISOString() },
|
||||
context: {
|
||||
source: inputPath,
|
||||
sourceSystem: input?.source.system ?? null,
|
||||
exportId: input?.source.exportId ?? null,
|
||||
network: "production-real-user",
|
||||
routeAggregation: "route-id-only",
|
||||
releaseId: input.releaseId,
|
||||
releaseId: input?.releaseId ?? null,
|
||||
privacyApprovalRef: input?.privacy.approvalRef ?? null,
|
||||
thresholdDecisionRef: input?.thresholdDecision.evidenceRef ?? null,
|
||||
validationFailures: validation.failures,
|
||||
},
|
||||
metrics,
|
||||
thresholds,
|
||||
eligibility: {
|
||||
consentRequired: true,
|
||||
totalSamples: input?.samples.length ?? 0,
|
||||
eligibleSamples: eligible.length,
|
||||
minimumEligibleSamples,
|
||||
routeSamples,
|
||||
},
|
||||
status: result.status,
|
||||
passed: result.passed,
|
||||
status,
|
||||
passed,
|
||||
};
|
||||
|
||||
await mkdir("artifacts/performance", { recursive: true });
|
||||
@@ -93,9 +97,9 @@ await writeFile(
|
||||
"artifacts/performance/field-web-vitals.json",
|
||||
`${JSON.stringify(report, null, 2)}\n`,
|
||||
);
|
||||
if (!result.passed) {
|
||||
if (!passed) {
|
||||
process.stderr.write(
|
||||
`Field Web Vitals: ${result.status} (minimum eligible sample threshold and 28-day production data are required)\n`,
|
||||
`Field Web Vitals: ${status} (approved threshold decision and valid 28-day production evidence are required)\n`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* @typedef {{
|
||||
* file: string,
|
||||
* isEntry?: boolean,
|
||||
* imports?: string[]
|
||||
* }} ViteManifestEntry
|
||||
*/
|
||||
|
||||
/**
|
||||
* Static imports of an entry are part of initial JavaScript. Every remaining
|
||||
* JavaScript output is governed by the lazy-chunk budget.
|
||||
*
|
||||
* @param {Record<string, ViteManifestEntry>} manifest
|
||||
*/
|
||||
export function classifyViteJavascript(manifest) {
|
||||
const initialFiles = new Set();
|
||||
const visitedKeys = new Set();
|
||||
const pendingKeys = Object.entries(manifest)
|
||||
.filter(([, entry]) => entry.isEntry)
|
||||
.map(([key]) => key);
|
||||
const missingImports = [];
|
||||
|
||||
while (pendingKeys.length > 0) {
|
||||
const key = /** @type {string} */ (pendingKeys.pop());
|
||||
if (visitedKeys.has(key)) continue;
|
||||
visitedKeys.add(key);
|
||||
const entry = manifest[key];
|
||||
if (!entry) {
|
||||
missingImports.push(key);
|
||||
continue;
|
||||
}
|
||||
if (entry.file.endsWith(".js")) initialFiles.add(entry.file);
|
||||
pendingKeys.push(...(entry.imports ?? []));
|
||||
}
|
||||
|
||||
const allJavaScript = new Set(
|
||||
Object.values(manifest)
|
||||
.map((entry) => entry.file)
|
||||
.filter((file) => file.endsWith(".js")),
|
||||
);
|
||||
const lazyFiles = [...allJavaScript].filter(
|
||||
(file) => !initialFiles.has(file),
|
||||
);
|
||||
return Object.freeze({
|
||||
initialFiles: Object.freeze([...initialFiles].sort()),
|
||||
lazyFiles: Object.freeze(lazyFiles.sort()),
|
||||
missingImports: Object.freeze(missingImports.sort()),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const WINDOW_MILLISECONDS = 28 * 24 * 60 * 60 * 1000;
|
||||
const nonEmptyString = z.string().trim().min(1);
|
||||
const timestamp = nonEmptyString.refine(
|
||||
(value) => Number.isFinite(Date.parse(value)),
|
||||
"must be an RFC 3339 timestamp",
|
||||
);
|
||||
const sampleSchema = z
|
||||
.object({
|
||||
timestamp,
|
||||
consent: z.boolean(),
|
||||
releaseId: nonEmptyString,
|
||||
routeId: nonEmptyString.regex(/^[A-Z][A-Z0-9_]*$/),
|
||||
lcpMs: z.number().finite().nonnegative(),
|
||||
cls: z.number().finite().nonnegative(),
|
||||
inpMs: z.number().finite().nonnegative(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const fieldEvidenceInputSchema = z
|
||||
.object({
|
||||
schemaVersion: z.literal(1),
|
||||
environment: z.literal("production"),
|
||||
releaseId: nonEmptyString.refine(
|
||||
(value) => value !== "local-release",
|
||||
"must identify an immutable production release",
|
||||
),
|
||||
source: z
|
||||
.object({
|
||||
system: nonEmptyString,
|
||||
exportId: nonEmptyString,
|
||||
})
|
||||
.strict(),
|
||||
privacy: z
|
||||
.object({
|
||||
approved: z.literal(true),
|
||||
approvalRef: nonEmptyString,
|
||||
})
|
||||
.strict(),
|
||||
window: z
|
||||
.object({
|
||||
start: timestamp,
|
||||
end: timestamp,
|
||||
})
|
||||
.strict(),
|
||||
thresholdDecision: z
|
||||
.object({
|
||||
status: z.literal("approved"),
|
||||
minimumEligibleSamples: z.number().int().positive(),
|
||||
owner: nonEmptyString,
|
||||
reviewedAt: timestamp,
|
||||
evidenceRef: nonEmptyString,
|
||||
})
|
||||
.strict(),
|
||||
samples: z.array(sampleSchema),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((input, context) => {
|
||||
const start = Date.parse(input.window.start);
|
||||
const end = Date.parse(input.window.end);
|
||||
if (end - start !== WINDOW_MILLISECONDS) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["window"],
|
||||
message: "must cover exactly 28 days",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @param {unknown} input
|
||||
* @param {string | undefined} configuredMinimum
|
||||
* @param {Date} [now]
|
||||
*/
|
||||
export function validateFieldEvidenceInput(
|
||||
input,
|
||||
configuredMinimum,
|
||||
now = new Date(),
|
||||
) {
|
||||
const parsed = fieldEvidenceInputSchema.safeParse(input);
|
||||
const failures = parsed.success
|
||||
? []
|
||||
: parsed.error.issues.map(
|
||||
(issue) => `${issue.path.join(".") || "input"}: ${issue.message}`,
|
||||
);
|
||||
const minimumEligibleSamples = Number(configuredMinimum);
|
||||
if (
|
||||
configuredMinimum === undefined ||
|
||||
!Number.isInteger(minimumEligibleSamples) ||
|
||||
minimumEligibleSamples <= 0
|
||||
) {
|
||||
failures.push("MIN_ELIGIBLE_SAMPLES: must be a positive integer");
|
||||
}
|
||||
|
||||
if (parsed.success) {
|
||||
if (
|
||||
parsed.data.thresholdDecision.minimumEligibleSamples !==
|
||||
minimumEligibleSamples
|
||||
) {
|
||||
failures.push(
|
||||
"MIN_ELIGIBLE_SAMPLES: does not match the approved threshold decision",
|
||||
);
|
||||
}
|
||||
if (Date.parse(parsed.data.window.end) > now.getTime()) {
|
||||
failures.push("window.end: must not be in the future");
|
||||
}
|
||||
if (Date.parse(parsed.data.thresholdDecision.reviewedAt) > now.getTime()) {
|
||||
failures.push("thresholdDecision.reviewedAt: must not be in the future");
|
||||
}
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
data: parsed.success ? parsed.data : null,
|
||||
failures: Object.freeze(failures),
|
||||
minimumEligibleSamples:
|
||||
Number.isInteger(minimumEligibleSamples) && minimumEligibleSamples > 0
|
||||
? minimumEligibleSamples
|
||||
: null,
|
||||
passed: parsed.success && failures.length === 0,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { classifyViteJavascript } from "../../scripts/lib/classify-vite-bundle.mjs";
|
||||
|
||||
describe("Vite bundle classification", () => {
|
||||
it("counts transitive static imports as initial and keeps dynamic chunks lazy", () => {
|
||||
expect(
|
||||
classifyViteJavascript({
|
||||
"index.html": {
|
||||
file: "assets/entry.js",
|
||||
isEntry: true,
|
||||
imports: ["_shared.js"],
|
||||
},
|
||||
"_shared.js": { file: "assets/shared.js", imports: ["_runtime.js"] },
|
||||
"_runtime.js": { file: "assets/runtime.js" },
|
||||
"src/lazy.js": { file: "assets/lazy.js" },
|
||||
}),
|
||||
).toEqual({
|
||||
initialFiles: [
|
||||
"assets/entry.js",
|
||||
"assets/runtime.js",
|
||||
"assets/shared.js",
|
||||
],
|
||||
lazyFiles: ["assets/lazy.js"],
|
||||
missingImports: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a manifest import that cannot be resolved", () => {
|
||||
expect(
|
||||
classifyViteJavascript({
|
||||
"index.html": {
|
||||
file: "assets/entry.js",
|
||||
isEntry: true,
|
||||
imports: ["_missing.js"],
|
||||
},
|
||||
}).missingImports,
|
||||
).toEqual(["_missing.js"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { validateFieldEvidenceInput } from "../../scripts/lib/field-vitals-evidence.mjs";
|
||||
|
||||
const input = {
|
||||
schemaVersion: 1,
|
||||
environment: "production",
|
||||
releaseId: "release-2026-06-29",
|
||||
source: {
|
||||
system: "privacy-approved-rum-export",
|
||||
exportId: "export-2026-06-29",
|
||||
},
|
||||
privacy: {
|
||||
approved: true,
|
||||
approvalRef: "PRIVACY-42",
|
||||
},
|
||||
window: {
|
||||
start: "2026-06-01T00:00:00Z",
|
||||
end: "2026-06-29T00:00:00Z",
|
||||
},
|
||||
thresholdDecision: {
|
||||
status: "approved",
|
||||
minimumEligibleSamples: 25,
|
||||
owner: "performance-owner",
|
||||
reviewedAt: "2026-06-30T00:00:00Z",
|
||||
evidenceRef: "PERF-BASELINE-7",
|
||||
},
|
||||
samples: [
|
||||
{
|
||||
timestamp: "2026-06-20T00:00:00Z",
|
||||
consent: true,
|
||||
releaseId: "release-2026-06-29",
|
||||
routeId: "APP_HOME",
|
||||
lcpMs: 1200,
|
||||
cls: 0.01,
|
||||
inpMs: 80,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe("field Web Vitals evidence input", () => {
|
||||
it("accepts reviewed, coherent 28-day production metadata", () => {
|
||||
expect(
|
||||
validateFieldEvidenceInput(
|
||||
input,
|
||||
"25",
|
||||
new Date("2026-07-01T00:00:00Z"),
|
||||
),
|
||||
).toMatchObject({
|
||||
failures: [],
|
||||
minimumEligibleSamples: 25,
|
||||
passed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a threshold that does not match the owner decision", () => {
|
||||
expect(
|
||||
validateFieldEvidenceInput(
|
||||
input,
|
||||
"10",
|
||||
new Date("2026-07-01T00:00:00Z"),
|
||||
),
|
||||
).toMatchObject({
|
||||
failures: [
|
||||
"MIN_ELIGIBLE_SAMPLES: does not match the approved threshold decision",
|
||||
],
|
||||
passed: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects local, unapproved, malformed, or impossible measurements", () => {
|
||||
const invalid = {
|
||||
...input,
|
||||
environment: "local",
|
||||
releaseId: "local-release",
|
||||
privacy: { approved: false, approvalRef: "" },
|
||||
window: { ...input.window, end: "2026-06-28T00:00:00Z" },
|
||||
samples: [{ ...input.samples[0], lcpMs: -1 }],
|
||||
};
|
||||
const validation = validateFieldEvidenceInput(
|
||||
invalid,
|
||||
"-1",
|
||||
new Date("2026-07-01T00:00:00Z"),
|
||||
);
|
||||
expect(validation.passed).toBe(false);
|
||||
expect(validation.failures.join("\n")).toContain("environment");
|
||||
expect(validation.failures.join("\n")).toContain("releaseId");
|
||||
expect(validation.failures.join("\n")).toContain("privacy");
|
||||
expect(validation.failures.join("\n")).toContain("lcpMs");
|
||||
expect(validation.failures.join("\n")).toContain(
|
||||
"MIN_ELIGIBLE_SAMPLES: must be a positive integer",
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user