perf: enforce journey budgets and reference traces
This commit is contained in:
11
scripts/aggregate-drive-traces.mjs
Normal file
11
scripts/aggregate-drive-traces.mjs
Normal file
@@ -0,0 +1,11 @@
|
||||
#!/usr/bin/env node
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { aggregateFiveDriveTraces } from './lib/performance-aggregation.mjs';
|
||||
|
||||
const paths = process.argv.slice(2);
|
||||
if (paths.length !== 5) throw new Error('Pass exactly five explicit drive-trace.v1 JSON paths.');
|
||||
const runs = [];
|
||||
for (const path of paths) runs.push(JSON.parse(await readFile(path, 'utf8')));
|
||||
const aggregate = aggregateFiveDriveTraces(runs);
|
||||
process.stdout.write(`${JSON.stringify(aggregate, null, 2)}\n`);
|
||||
if (!aggregate.passes) process.exitCode = 1;
|
||||
80
scripts/check-bundle-budgets.mjs
Normal file
80
scripts/check-bundle-budgets.mjs
Normal file
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env node
|
||||
import { brotliCompressSync, constants as zlibConstants } from 'node:zlib';
|
||||
import { readFile, readdir, stat } from 'node:fs/promises';
|
||||
import { relative, resolve, sep } from 'node:path';
|
||||
import { classifyArtifact, evaluateBudgetTotals, PERFORMANCE_BUDGETS } from './lib/bundle-budgets.mjs';
|
||||
|
||||
const root = resolve(process.argv[2] ?? 'dist');
|
||||
if (!(await stat(root)).isDirectory()) throw new Error('Bundle budget target must be a directory.');
|
||||
|
||||
const listFiles = async (directory) => {
|
||||
const files = [];
|
||||
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
||||
const path = resolve(directory, entry.name);
|
||||
if (entry.isSymbolicLink()) throw new Error('Bundle budget input cannot contain symbolic links.');
|
||||
if (entry.isDirectory()) files.push(...await listFiles(path));
|
||||
else if (entry.isFile()) files.push(path);
|
||||
else throw new Error('Bundle budget input contains an unsupported entry.');
|
||||
}
|
||||
return files;
|
||||
};
|
||||
|
||||
const compressedSize = (path, bytes) => (/\.(?:avif|br|gif|jpe?g|mp4|png|webm|webp|woff2?)$/iu.test(path)
|
||||
? bytes.byteLength
|
||||
: brotliCompressSync(bytes, {
|
||||
params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 11 },
|
||||
}).byteLength);
|
||||
|
||||
const records = [];
|
||||
for (const absolute of await listFiles(root)) {
|
||||
const path = relative(root, absolute).split(sep).join('/');
|
||||
const bytes = await readFile(absolute);
|
||||
const source = /\.(?:css|html|js|json|txt|xml)$/u.test(path) ? bytes.toString('utf8') : '';
|
||||
const bucket = classifyArtifact(path, source);
|
||||
records.push({ path, bucket, bytes: bytes.byteLength, transferBytes: compressedSize(path, bytes) });
|
||||
}
|
||||
|
||||
const sum = (bucket) => records.filter((record) => record.bucket === bucket).reduce((total, record) => total + record.transferBytes, 0);
|
||||
const totals = {
|
||||
initialHtmlCriticalCss: sum('initial-html') + sum('critical-css'),
|
||||
initialApplicationJs: sum('initial-application-js'),
|
||||
heroPoster: sum('hero-poster'),
|
||||
initialFonts: sum('initial-fonts'),
|
||||
firstLiveScene: sum('first-live-scene'),
|
||||
deferredProjectVisuals: sum('project-still') + sum('chart'),
|
||||
deferredNonVideo: sum('first-live-scene') + sum('project-still') + sum('chart'),
|
||||
};
|
||||
|
||||
for (const record of records.filter((item) => item.bucket === 'project-video')) {
|
||||
if (record.transferBytes > PERFORMANCE_BUDGETS.projectVideo) {
|
||||
throw new Error(`Project video exceeds its transfer budget: ${record.path}`);
|
||||
}
|
||||
}
|
||||
for (const record of records.filter((item) => item.bucket === 'project-still')) {
|
||||
if (record.transferBytes > PERFORMANCE_BUDGETS.projectStill) {
|
||||
throw new Error(`Project still exceeds its transfer budget: ${record.path}`);
|
||||
}
|
||||
}
|
||||
for (const record of records.filter((item) => item.bucket === 'chart')) {
|
||||
if (record.transferBytes > PERFORMANCE_BUDGETS.chart) {
|
||||
throw new Error(`Chart exceeds its transfer budget: ${record.path}`);
|
||||
}
|
||||
}
|
||||
|
||||
const entry = records.find((record) => record.bucket === 'initial-application-js');
|
||||
const scene = records.find((record) => record.bucket === 'first-live-scene');
|
||||
if (entry === undefined || scene === undefined) throw new Error('Both initial application and deferred live-scene chunks are required.');
|
||||
const entrySource = await readFile(resolve(root, entry.path), 'utf8');
|
||||
if (!/import\([^)]*environment-/u.test(entrySource)) throw new Error('The live scene is not separated behind a dynamic import.');
|
||||
if (/WebGLRenderer|WebGLProgram|ShaderMaterial/u.test(entrySource)) throw new Error('Three.js/live-scene code leaked into the reduced-motion initial chunk.');
|
||||
|
||||
const result = evaluateBudgetTotals(totals);
|
||||
const report = {
|
||||
schema: 'portfolio-bundle-budget/v1',
|
||||
compression: 'brotli-quality-11-or-raw-already-compressed',
|
||||
totals,
|
||||
records: records.sort((left, right) => left.path.localeCompare(right.path)),
|
||||
violations: result.violations,
|
||||
};
|
||||
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
||||
if (result.violations.length > 0) process.exitCode = 1;
|
||||
5
scripts/lib/bundle-budgets.d.mts
Normal file
5
scripts/lib/bundle-budgets.d.mts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const PERFORMANCE_BUDGETS: Readonly<Record<string, number>>;
|
||||
export function classifyArtifact(path: string, source?: string): string;
|
||||
export function evaluateBudgetTotals(totals: Readonly<Record<string, number>>): {
|
||||
violations: Array<{ budget: string; actual: number; threshold: number }>;
|
||||
};
|
||||
51
scripts/lib/bundle-budgets.mjs
Normal file
51
scripts/lib/bundle-budgets.mjs
Normal file
@@ -0,0 +1,51 @@
|
||||
export const PERFORMANCE_BUDGETS = Object.freeze({
|
||||
initialHtmlCriticalCss: 100 * 1024,
|
||||
initialApplicationJs: 120 * 1024,
|
||||
heroPoster: 220 * 1024,
|
||||
initialFonts: 120 * 1024,
|
||||
firstLiveScene: 650 * 1024,
|
||||
projectVideo: 2.5 * 1024 * 1024,
|
||||
projectVideoDurationSeconds: 10,
|
||||
projectVideoWidth: 1280,
|
||||
projectVideoHeight: 720,
|
||||
projectStill: 400 * 1024,
|
||||
chart: 100 * 1024,
|
||||
deferredProjectVisuals: 2.5 * 1024 * 1024,
|
||||
highTierExtras: 750 * 1024,
|
||||
deferredNonVideo: 3.5 * 1024 * 1024,
|
||||
imageSceneConcurrency: 2,
|
||||
videoConcurrency: 1,
|
||||
mobileTextureDimension: 2048,
|
||||
lowTierGpuEstimate: 96 * 1024 * 1024,
|
||||
highTierGpuEstimate: 192 * 1024 * 1024,
|
||||
});
|
||||
|
||||
export const classifyArtifact = (path, source = '') => {
|
||||
if (path === 'index.html') return 'initial-html';
|
||||
if (/^assets\/index-[^/]+\.css$/u.test(path)) return 'critical-css';
|
||||
if (/^assets\/index-[^/]+\.js$/u.test(path) && /import\(/u.test(source)) return 'initial-application-js';
|
||||
if (/^assets\/environment-[^/]+\.js$/u.test(path)) return 'first-live-scene';
|
||||
if (/\.(?:woff2?|ttf|otf)$/u.test(path)) return 'initial-fonts';
|
||||
if (/^assets\/hero-[^/]+\.(?:avif|webp|png|jpe?g)$/u.test(path)) return 'hero-poster';
|
||||
if (/^assets\/project-[^/]+\.(?:webm|mp4)$/u.test(path)) return 'project-video';
|
||||
if (/^assets\/project-[^/]*chart[^/]*\.(?:avif|webp|png|jpe?g)$/u.test(path)) return 'chart';
|
||||
if (/^assets\/project-[^/]+\.(?:avif|webp|png|jpe?g|svg)$/u.test(path)) return 'project-still';
|
||||
if (/^(?:robots\.txt|sitemap\.xml|_headers\.example|_redirects\.example)$/u.test(path)) return 'release-metadata';
|
||||
throw new Error(`Artifact is unclassified: ${path}`);
|
||||
};
|
||||
|
||||
const thresholdByTotal = Object.freeze({
|
||||
initialHtmlCriticalCss: PERFORMANCE_BUDGETS.initialHtmlCriticalCss,
|
||||
initialApplicationJs: PERFORMANCE_BUDGETS.initialApplicationJs,
|
||||
heroPoster: PERFORMANCE_BUDGETS.heroPoster,
|
||||
initialFonts: PERFORMANCE_BUDGETS.initialFonts,
|
||||
firstLiveScene: PERFORMANCE_BUDGETS.firstLiveScene,
|
||||
deferredProjectVisuals: PERFORMANCE_BUDGETS.deferredProjectVisuals,
|
||||
deferredNonVideo: PERFORMANCE_BUDGETS.deferredNonVideo,
|
||||
});
|
||||
|
||||
export const evaluateBudgetTotals = (totals) => ({
|
||||
violations: Object.entries(thresholdByTotal)
|
||||
.filter(([budget, threshold]) => (totals[budget] ?? 0) > threshold)
|
||||
.map(([budget, threshold]) => ({ budget, actual: totals[budget], threshold })),
|
||||
});
|
||||
7
scripts/lib/performance-aggregation.d.mts
Normal file
7
scripts/lib/performance-aggregation.d.mts
Normal file
@@ -0,0 +1,7 @@
|
||||
export function aggregateFiveDriveTraces(runs: readonly unknown[]): {
|
||||
schema: 'drive-trace-aggregation/v1';
|
||||
profile: string;
|
||||
runCount: 5;
|
||||
summary: Record<string, number | boolean>;
|
||||
passes: boolean;
|
||||
};
|
||||
37
scripts/lib/performance-aggregation.mjs
Normal file
37
scripts/lib/performance-aggregation.mjs
Normal file
@@ -0,0 +1,37 @@
|
||||
const median = (values) => {
|
||||
if (values.length === 0) throw new Error('At least one value is required.');
|
||||
const sorted = [...values].sort((left, right) => left - right);
|
||||
return sorted[Math.floor(sorted.length / 2)];
|
||||
};
|
||||
|
||||
export const aggregateFiveDriveTraces = (runs) => {
|
||||
if (!Array.isArray(runs) || runs.length !== 5) throw new Error('Exactly five drive-trace.v1 runs are required.');
|
||||
const profile = runs[0]?.profile;
|
||||
if (typeof profile !== 'string' || runs.some((run) => run?.schema !== 'drive-trace.v1' || run.profile !== profile)) {
|
||||
throw new Error('All runs must use one drive-trace.v1 profile.');
|
||||
}
|
||||
const lcp = runs.map((run) => run.metrics.lcp);
|
||||
const tbt = runs.map((run) => run.metrics.tbt);
|
||||
const everyPerRunBudgetPass = runs.every((run) => Object.values(run.budgetResults).every(Boolean));
|
||||
const referenceEvidence = runs.every((run) => run.gatingReferenceResult === true && run.validReferenceCadence === true);
|
||||
const summary = {
|
||||
lcpWorst: Math.max(...lcp),
|
||||
lcpMedian: median(lcp),
|
||||
tbtWorst: Math.max(...tbt),
|
||||
tbtMedian: median(tbt),
|
||||
everyPerRunBudgetPass,
|
||||
referenceEvidence,
|
||||
};
|
||||
return {
|
||||
schema: 'drive-trace-aggregation/v1',
|
||||
profile,
|
||||
runCount: 5,
|
||||
summary,
|
||||
passes: referenceEvidence
|
||||
&& everyPerRunBudgetPass
|
||||
&& summary.lcpWorst <= 3_000
|
||||
&& summary.lcpMedian <= 2_500
|
||||
&& summary.tbtWorst <= 300
|
||||
&& summary.tbtMedian <= 200,
|
||||
};
|
||||
};
|
||||
@@ -16,6 +16,8 @@ const canonicalSectionIds = [
|
||||
const prohibitedSurfaces = [
|
||||
/__PORTFOLIO_INPUTS__/iu,
|
||||
/__PORTFOLIO_TEST_HOOKS__/iu,
|
||||
/__PORTFOLIO_PERFORMANCE__/iu,
|
||||
/__portfolio_performance/iu,
|
||||
/PORTFOLIO_TEST_TOKEN/iu,
|
||||
/__PORTFOLIO_(?:TEST|INSTRUMENTATION)__/iu,
|
||||
/__portfolio(?:Journey)?Instrumentation/iu,
|
||||
|
||||
Reference in New Issue
Block a user