108 lines
3.3 KiB
TypeScript
108 lines
3.3 KiB
TypeScript
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
|
|
import {
|
|
evaluateFieldBudget,
|
|
percentile75,
|
|
} from "../src/application/policies/performance-budgets.ts";
|
|
import { validateFieldEvidenceInput } from "./lib/field-vitals-evidence.ts";
|
|
|
|
const inputPath =
|
|
process.env.FIELD_WEB_VITALS_INPUT ||
|
|
"config/performance/field-input.example.json";
|
|
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;
|
|
type FieldBudgets = {
|
|
p75LcpMs: number;
|
|
p75Cls: number;
|
|
p75InpMs: number;
|
|
minimumEligibleSamples: number | null;
|
|
};
|
|
|
|
const configured = JSON.parse(
|
|
await readFile("config/performance/budgets.json", "utf8"),
|
|
).field as FieldBudgets;
|
|
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 &&
|
|
timestamp >= start &&
|
|
timestamp <= end
|
|
);
|
|
});
|
|
const metrics = {
|
|
p75LcpMs: percentile75(eligible.map((sample) => sample.lcpMs)),
|
|
p75Cls: percentile75(eligible.map((sample) => sample.cls)),
|
|
p75InpMs: percentile75(eligible.map((sample) => sample.inpMs)),
|
|
};
|
|
const thresholds = { ...configured, minimumEligibleSamples };
|
|
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(
|
|
(counts, sample) => {
|
|
counts[sample.routeId] = (counts[sample.routeId] ?? 0) + 1;
|
|
return counts;
|
|
},
|
|
{} as Record<string, number>,
|
|
),
|
|
).sort(([left], [right]) => left.localeCompare(right)),
|
|
);
|
|
const report = {
|
|
schemaVersion: 1,
|
|
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 ?? 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,
|
|
passed,
|
|
};
|
|
|
|
await mkdir("artifacts/performance", { recursive: true });
|
|
await writeFile(
|
|
"artifacts/performance/field-web-vitals.json",
|
|
`${JSON.stringify(report, null, 2)}\n`,
|
|
);
|
|
if (!passed) {
|
|
process.stderr.write(
|
|
`Field Web Vitals: ${status} (approved threshold decision and valid 28-day production evidence are required)\n`,
|
|
);
|
|
process.exit(1);
|
|
}
|
|
process.stdout.write("Field Web Vitals: PASS\n");
|