Files
iptvnator/tools/performance/check-baseline-direction.mjs
T
4grayandClaude Fable 5.1 d3b6e548cc ci(performance): fail when the web app's initial bytes grow (#1694)
Third step of the performance-journeys ratchet, stacked on #1693 (which is stacked on #1692; merge in order, GitHub retargets each to `master`).

- New `Initial bytes ratchet` job in `.github/workflows/ci.yml` (ubuntu-latest): install, `pnpm nx build web --skip-nx-cache` (production configuration, the one users download), then `pnpm run perf:initial-bytes:check`. The job fails when `renderer.initialBytes` exceeds `tools/performance/journey-baselines.json`.
- `dist/performance/` is uploaded as the `performance-journey-summary` artifact on every run, so a failing or tightenable run carries its evidence.
- After review: the job first runs the new `tools/performance/check-baseline-direction.mjs`, which compares `journey-baselines.json` with the revision the change is measured against (the target branch of a pull request, `github.event.before` for a `master` push, `master` for a manual dispatch) and fails on any raised enforced limit (`value × toleranceRatio`), any widened or newly added tolerance, or any removed entry, so a PR cannot grow the payload and raise the baseline to match (lowered limits and new entries pass; a target branch without the file has nothing to weaken). Node tests cover it.
- Docs: the performance-journeys contract and the validation map name the job, and the contract now states that this runner is the canonical measurer (take baseline values from its output, not from a local build).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-26 13:44:01 +02:00

176 lines
6.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Refuses a change that weakens tools/performance/journey-baselines.json.
*
* The ratchet job compares a measurement with the baselines file of the same
* commit, so on its own it cannot tell a genuine payload reduction from a PR
* that grows the payload and raises the baseline by the same amount. This
* check closes that gap: given the baselines file of the target branch and
* the one of the PR, any entry whose enforced limit (`value × toleranceRatio`)
* went up, whose tolerance widened, or that disappeared, is a failure. New
* entries and lowered limits pass.
*
* Usage:
* node tools/performance/check-baseline-direction.mjs \
* --base <target-branch-journey-baselines.json> \
* --head tools/performance/journey-baselines.json
*
* A missing --base file means the target branch has no baselines yet, so
* there is nothing that could have been weakened.
*/
import { existsSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { validateBaselines } from './check-journey-ratchet.mjs';
export const DEFAULT_HEAD_PATH = 'tools/performance/journey-baselines.json';
function entries(baselines) {
const flat = new Map();
for (const [journey, counters] of Object.entries(baselines.journeys)) {
for (const [name, entry] of Object.entries(counters)) {
flat.set(`${journey}/${name}`, entry);
}
}
return flat;
}
function formatNumber(value) {
return value.toLocaleString('en-US', { maximumFractionDigits: 2 });
}
/**
* What the ratchet actually enforces: `value × toleranceRatio` for a
* wall-clock entry, the bare value for a counter. Comparing values alone would
* let a PR lower a value while widening the tolerance.
*/
function effectiveLimit(entry) {
return entry.value * (entry.toleranceRatio ?? 1);
}
/** Pure comparison; `failures` non-empty means the change weakens the ratchet. */
export function compareBaselineDirection({ base, head }) {
validateBaselines(base);
validateBaselines(head);
const result = { failures: [], lowered: [], unchanged: [], added: [] };
const headEntries = entries(head);
for (const [label, baseEntry] of entries(base)) {
const headEntry = headEntries.get(label);
const unit = baseEntry.unit ? ` ${baseEntry.unit}` : '';
if (!headEntry) {
result.failures.push(
`${label}: baseline ${formatNumber(baseEntry.value)}${unit} was removed. Baselines are retired only by a maintainer decision recorded in the PR, not by deleting the entry.`
);
continue;
}
const baseTolerance = baseEntry.toleranceRatio ?? 1;
const headTolerance = headEntry.toleranceRatio ?? 1;
const baseLimit = effectiveLimit(baseEntry);
const headLimit = effectiveLimit(headEntry);
if (headTolerance > baseTolerance) {
result.failures.push(
`${label}: toleranceRatio widened from ${baseTolerance} to ${headTolerance}. Tolerances are a maintainer decision; a PR may only narrow them.`
);
} else if (headLimit > baseLimit) {
result.failures.push(
`${label}: baseline raised from ${formatNumber(baseLimit)} to ${formatNumber(headLimit)}${unit}. Baselines only move down; bring the measurement back under ${formatNumber(baseLimit)} or make the case for the increase in the PR.`
);
} else if (headLimit < baseLimit) {
result.lowered.push(
`${label}: ${formatNumber(baseLimit)} -> ${formatNumber(headLimit)}${unit}.`
);
} else {
result.unchanged.push(label);
}
}
for (const label of headEntries.keys()) {
if (!entries(base).has(label)) result.added.push(label);
}
return result;
}
export function formatDirectionResult(result) {
const lines = [];
for (const line of result.lowered) lines.push(`lowered ${line}`);
for (const label of result.added) lines.push(`added ${label}`);
for (const line of result.failures) lines.push(`FAIL ${line}`);
lines.push(
result.failures.length > 0
? `Baseline direction check failed: ${result.failures.length} entries raised or removed.`
: `Baseline direction OK: ${result.unchanged.length} unchanged, ${result.lowered.length} lowered, ${result.added.length} added.`
);
return lines.join('\n');
}
export function parseArgs(argv) {
const options = { base: null, head: DEFAULT_HEAD_PATH };
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
if (argument === '--') continue;
if (argument === '--base') {
options.base = argv[++index];
} else if (argument.startsWith('--base=')) {
options.base = argument.slice('--base='.length);
} else if (argument === '--head') {
options.head = argv[++index];
} else if (argument.startsWith('--head=')) {
options.head = argument.slice('--head='.length);
} else {
throw new Error(`Unknown argument: ${argument}`);
}
if (options.base === undefined || options.head === undefined) {
throw new Error(`Missing value for ${argument}`);
}
}
if (!options.base) {
throw new Error(
'--base <target-branch-journey-baselines.json> is required.'
);
}
return options;
}
async function readJson(filePath, description) {
try {
return JSON.parse(await readFile(filePath, 'utf8'));
} catch (error) {
throw new Error(
`Cannot read ${description} at ${filePath}: ${error.message}`
);
}
}
const isMain =
process.argv[1] &&
path.resolve(process.argv[1]) ===
path.resolve(fileURLToPath(import.meta.url));
if (isMain) {
try {
const options = parseArgs(process.argv.slice(2));
const basePath = path.resolve(options.base);
const base = existsSync(basePath)
? await readJson(basePath, 'target-branch baselines')
: { version: 1, journeys: {} };
if (!existsSync(basePath)) {
console.log(
`No baselines file at ${options.base} on the target branch; nothing to weaken.`
);
}
const head = await readJson(path.resolve(options.head), 'baselines');
const result = compareBaselineDirection({ base, head });
const output = formatDirectionResult(result);
if (result.failures.length > 0) {
console.error(output);
process.exitCode = 1;
} else {
console.log(output);
}
} catch (error) {
console.error(`check-baseline-direction: ${error.message}`);
process.exitCode = 1;
}
}