Files
iptvnator/tools/performance/check-journey-ratchet.mjs
T
4grayandClaude Fable 5.1 7abaad29e7 chore(performance): commit the initial-bytes baseline and ratchet checker (#1693)
Second step of the performance-journeys ratchet, stacked on #1692 (merge that first; this PR retargets to `master` automatically).

- `tools/performance/journey-baselines.json`: J1 `launch` / `renderer.initialBytes` = **2,739,510 bytes**, the ubuntu runner's production build of `apps/web` at this content (after #1692 stopped bundling `package.json` into `main.js`). A local macOS build of this pre-#1695 code is 2 bytes smaller in `main.js` (the eager locale imports); once #1695 removes them the two are byte-identical. Correction to an earlier version of this description: the "556-byte macOS vs Linux difference" was almost entirely `package.json` text embedded in `main.js`, which moved with every script edit in this stack, plus this 2-byte residue. The runner is the canonical measurer; the CI run on the stacked #1694 branch (this content plus the job) is where the number is confirmed.
- `tools/performance/check-journey-ratchet.mjs` compares a journey summary with the baselines: a counter above its value fails (exact, no slack), wall-clock entries fail above `value × toleranceRatio`, a baseline without a measurement fails so dropping a measurement cannot disable the ratchet, values below baseline print a "tighten" hint, and measured counters without a baseline are noted only. After review: checking nothing (empty file, or `--only` naming a missing entry) fails; a counter is read only from `counters` and a wall-clock entry only from `wallClock`; the repeatable `--only <journey>/<counter>` flag scopes a check.
- Root scripts: `perf:initial-bytes:check` (measures into its own `dist/performance/initial-bytes.summary.json`, then checks `--only launch/renderer.initialBytes`) and `perf:ratchet:check` (full check); `perf:tools:test` runs both test files, as does `pnpm nx test performance-tools`.
- `docs/architecture/performance-journeys.md` gains the Ratchet section (file format, rules, "baselines only move down"); the validation map lists the check.

The CI job that runs the check on every PR is #1694; C1 (lazy Angular date locales, #1695) then lowers the baseline with the measured output as evidence.

Note: `ci.yml` only triggers on pull requests targeting `master`, so this stacked PR shows no Actions runs until #1692 merges. The evidence runs above were dispatched with `gh workflow run ci.yml --ref <branch>`.

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

298 lines
11 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.
/**
* Compares a journey summary (written by the measurement scripts) against the
* committed baselines in tools/performance/journey-baselines.json.
*
* Rules, from docs/architecture/performance-journeys.md:
* - A counter above its baseline fails. Counters are exact; there is no slack.
* - An entry with `toleranceRatio` (wall-clock) fails above
* `value * toleranceRatio`.
* - A baseline without a measurement fails, so removing a measurement can
* never silently disable the ratchet.
* - A measurement below its baseline prints a "can tighten" hint. Baselines
* are lowered by hand, with the measured output as evidence, never raised.
* - Checking nothing is a failure: an empty baselines file, or `--only`
* naming an entry that does not exist, must not exit 0.
*
* `--only <journey>/<counter>` (repeatable) restricts the check to the named
* baselines, so a script that measures one counter can check that counter
* without every other baseline failing as unmeasured.
*
* Usage:
* node tools/performance/check-journey-ratchet.mjs --summary <summary.json>
* [--baselines tools/performance/journey-baselines.json]
* [--only launch/renderer.initialBytes ...]
*/
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
export const DEFAULT_BASELINES_PATH =
'tools/performance/journey-baselines.json';
function formatNumber(value) {
return Number.isInteger(value)
? value.toLocaleString('en-US')
: value.toLocaleString('en-US', { maximumFractionDigits: 2 });
}
function isPlainObject(value) {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
export function validateBaselines(baselines) {
if (!isPlainObject(baselines) || !isPlainObject(baselines.journeys)) {
throw new Error(
'Baselines file must be an object with a "journeys" map.'
);
}
for (const [journey, entries] of Object.entries(baselines.journeys)) {
if (!isPlainObject(entries)) {
throw new Error(
`Baseline journey "${journey}" must map counter names to entries.`
);
}
for (const [name, entry] of Object.entries(entries)) {
const label = `${journey}/${name}`;
if (!isPlainObject(entry) || !Number.isFinite(entry.value)) {
throw new Error(
`Baseline ${label} needs a finite numeric "value".`
);
}
if (
entry.toleranceRatio !== undefined &&
!(
Number.isFinite(entry.toleranceRatio) &&
entry.toleranceRatio >= 1
)
) {
throw new Error(
`Baseline ${label} has an invalid "toleranceRatio" (must be >= 1).`
);
}
}
}
return baselines;
}
/**
* A baseline entry is either a counter (exact) or a wall-clock entry (carries
* `toleranceRatio`), and each is read only from its own summary section. A
* value that moved to the other section is treated as missing, so a summary
* schema regression fails the ratchet instead of slipping through it.
*/
function summarySection(entry) {
return entry.toleranceRatio !== undefined ? 'wallClock' : 'counters';
}
function measuredValue(summaryJourney, name, entry) {
if (!isPlainObject(summaryJourney)) return undefined;
return summaryJourney[summarySection(entry)]?.[name];
}
/**
* Pure comparison. Returns every outcome so the CLI and tests can render it;
* `failures` non-empty means the ratchet is broken.
*/
export function compareToBaselines({ baselines, summary, only = [] }) {
validateBaselines(baselines);
const summaryJourneys = isPlainObject(summary?.journeys)
? summary.journeys
: {};
const result = {
failures: [],
tightenable: [],
passed: [],
unbaselined: [],
};
const selected = new Set(only);
let evaluated = 0;
for (const target of only) {
const [journey, ...rest] = target.split('/');
if (baselines.journeys[journey]?.[rest.join('/')] === undefined) {
result.failures.push(
`${target}: --only names a baseline that does not exist in the baselines file.`
);
}
}
for (const [journey, entries] of Object.entries(baselines.journeys)) {
for (const [name, entry] of Object.entries(entries)) {
const label = `${journey}/${name}`;
if (selected.size > 0 && !selected.has(label)) continue;
evaluated += 1;
const unit = entry.unit ? ` ${entry.unit}` : '';
const measured = measuredValue(
summaryJourneys[journey],
name,
entry
);
if (measured === undefined) {
result.failures.push(
`${label}: baseline ${formatNumber(entry.value)}${unit} has no measurement under journeys.${journey}.${summarySection(entry)} in the summary. The ratchet cannot be bypassed by dropping a measurement; restore it.`
);
continue;
}
if (typeof measured !== 'number' || !Number.isFinite(measured)) {
result.failures.push(
`${label}: measured value ${JSON.stringify(measured)} is not a finite number.`
);
continue;
}
const limit =
entry.toleranceRatio !== undefined
? entry.value * entry.toleranceRatio
: entry.value;
const limitText =
entry.toleranceRatio !== undefined
? `${formatNumber(limit)} (baseline ${formatNumber(entry.value)} × ${entry.toleranceRatio})`
: `baseline ${formatNumber(entry.value)}`;
if (measured > limit) {
result.failures.push(
`${label}: ${formatNumber(measured)}${unit} exceeds ${limitText} by ${formatNumber(measured - limit)}${unit}. Bring the value back down; baselines only move down. If the growth is a deliberate trade-off, say so in the PR and let the maintainer decide.`
);
} else if (measured < entry.value) {
result.tightenable.push(
`${label}: ${formatNumber(measured)}${unit} is below baseline ${formatNumber(entry.value)} by ${formatNumber(entry.value - measured)}${unit}. Lower the baseline in ${DEFAULT_BASELINES_PATH} with this run as evidence.`
);
} else {
result.passed.push(
`${label}: ${formatNumber(measured)}${unit} within ${limitText}.`
);
}
}
}
for (const [journey, summaryJourney] of Object.entries(summaryJourneys)) {
const measuredNames = [
...Object.keys(summaryJourney?.counters ?? {}),
...Object.keys(summaryJourney?.wallClock ?? {}),
];
for (const name of measuredNames) {
if (baselines.journeys[journey]?.[name] === undefined) {
result.unbaselined.push(
`${journey}/${name}: measured but has no baseline yet. Add one to ${DEFAULT_BASELINES_PATH} once the counter is validated.`
);
}
}
}
if (evaluated === 0) {
result.failures.push(
`No baselines were checked. ${DEFAULT_BASELINES_PATH} must keep at least one entry; a ratchet that guards nothing must not pass.`
);
}
return result;
}
export function formatResult(result) {
const lines = [];
for (const line of result.passed) lines.push(`ok ${line}`);
for (const line of result.tightenable) lines.push(`tighten ${line}`);
for (const line of result.unbaselined) lines.push(`note ${line}`);
for (const line of result.failures) lines.push(`FAIL ${line}`);
const checked =
result.passed.length +
result.tightenable.length +
result.failures.length;
lines.push(
result.failures.length > 0
? `Journey ratchet failed: ${result.failures.length} of ${checked} baselines exceeded.`
: `Journey ratchet OK: ${checked} baselines checked, ${result.tightenable.length} can be tightened.`
);
return lines.join('\n');
}
export function parseArgs(argv) {
const options = {
summary: null,
baselines: DEFAULT_BASELINES_PATH,
only: [],
};
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
if (argument === '--') continue;
if (argument === '--summary') {
options.summary = argv[++index];
} else if (argument.startsWith('--summary=')) {
options.summary = argument.slice('--summary='.length);
} else if (argument === '--baselines') {
options.baselines = argv[++index];
} else if (argument.startsWith('--baselines=')) {
options.baselines = argument.slice('--baselines='.length);
} else if (argument === '--only') {
options.only.push(argv[++index]);
} else if (argument.startsWith('--only=')) {
options.only.push(argument.slice('--only='.length));
} else {
throw new Error(`Unknown argument: ${argument}`);
}
if (
options.summary === undefined ||
options.baselines === undefined ||
options.only.includes(undefined)
) {
throw new Error(`Missing value for ${argument}`);
}
}
for (const target of options.only) {
if (!/^[^/]+\/.+$/.test(target)) {
throw new Error(
`--only expects <journey>/<counter>, received "${target}".`
);
}
}
if (!options.summary) {
throw new Error('--summary <journey-summary.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 baselines = await readJson(
path.resolve(options.baselines),
'baselines'
);
const summary = await readJson(
path.resolve(options.summary),
'journey summary'
);
const result = compareToBaselines({
baselines,
summary,
only: options.only,
});
const output = formatResult(result);
if (result.failures.length > 0) {
console.error(output);
process.exitCode = 1;
} else {
console.log(output);
}
} catch (error) {
console.error(`check-journey-ratchet: ${error.message}`);
process.exitCode = 1;
}
}