Files
iptvnator/tools/performance/check-journey-ratchet.test.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

414 lines
13 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.
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { after, before, test } from 'node:test';
import {
DEFAULT_BASELINES_PATH,
compareToBaselines,
formatResult,
parseArgs,
validateBaselines,
} from './check-journey-ratchet.mjs';
const scriptPath = fileURLToPath(
new URL('./check-journey-ratchet.mjs', import.meta.url)
);
const committedBaselinesPath = fileURLToPath(
new URL('./journey-baselines.json', import.meta.url)
);
const baselines = {
version: 1,
journeys: {
launch: {
'renderer.initialBytes': { value: 2750491, unit: 'bytes' },
spawnToFirstCardMs: {
value: 1000,
unit: 'ms',
toleranceRatio: 1.25,
},
},
},
};
const summaryWith = (initialBytes, wallClockMs = 1000) => ({
journeys: {
launch: {
counters: { 'renderer.initialBytes': initialBytes },
wallClock: { spawnToFirstCardMs: wallClockMs },
},
},
});
let workDir;
before(async () => {
workDir = await mkdtemp(path.join(os.tmpdir(), 'check-journey-ratchet-'));
});
after(async () => {
await rm(workDir, { recursive: true, force: true });
});
test('a counter equal to its baseline passes', () => {
const result = compareToBaselines({
baselines,
summary: summaryWith(2750491),
});
assert.deepEqual(result.failures, []);
assert.deepEqual(result.tightenable, []);
assert.equal(result.passed.length, 2);
assert.match(
result.passed[0],
/launch\/renderer\.initialBytes: 2,750,491 bytes within baseline 2,750,491/
);
});
test('a counter one byte above its baseline fails with the delta', () => {
const result = compareToBaselines({
baselines,
summary: summaryWith(2750492),
});
assert.equal(result.failures.length, 1);
assert.match(
result.failures[0],
/launch\/renderer\.initialBytes: 2,750,492 bytes exceeds baseline 2,750,491 by 1 bytes/
);
assert.match(result.failures[0], /baselines only move down/);
});
test('a counter below its baseline passes and asks to tighten', () => {
const result = compareToBaselines({
baselines,
summary: summaryWith(2600000),
});
assert.deepEqual(result.failures, []);
assert.equal(result.tightenable.length, 1);
assert.match(
result.tightenable[0],
/below baseline 2,750,491 by 150,491 bytes/
);
assert.match(
result.tightenable[0],
new RegExp(DEFAULT_BASELINES_PATH.replace(/\//g, '\\/'))
);
});
test('wall-clock entries fail only above value × toleranceRatio', () => {
const within = compareToBaselines({
baselines,
summary: summaryWith(2750491, 1250),
});
assert.deepEqual(within.failures, []);
assert.match(
within.passed[1],
/1,250 ms within 1,250 \(baseline 1,000 × 1\.25\)/
);
const above = compareToBaselines({
baselines,
summary: summaryWith(2750491, 1251),
});
assert.equal(above.failures.length, 1);
assert.match(
above.failures[0],
/spawnToFirstCardMs: 1,251 ms exceeds 1,250 \(baseline 1,000 × 1\.25\) by 1 ms/
);
const below = compareToBaselines({
baselines,
summary: summaryWith(2750491, 900),
});
assert.equal(below.tightenable.length, 1);
assert.match(
below.tightenable[0],
/spawnToFirstCardMs: 900 ms is below baseline 1,000/
);
});
test('a baseline without a measurement fails instead of being skipped', () => {
const result = compareToBaselines({
baselines,
summary: { journeys: { launch: { counters: {} } } },
});
assert.equal(result.failures.length, 2);
assert.match(
result.failures[0],
/renderer\.initialBytes: baseline 2,750,491 bytes has no measurement/
);
assert.match(
result.failures[0],
/cannot be bypassed by dropping a measurement/
);
});
test('a value in the wrong summary section counts as missing', () => {
const counterInWallClock = compareToBaselines({
baselines,
summary: {
journeys: {
launch: {
counters: {},
wallClock: {
'renderer.initialBytes': 2750491,
spawnToFirstCardMs: 1000,
},
},
},
},
});
assert.equal(counterInWallClock.failures.length, 1);
assert.match(
counterInWallClock.failures[0],
/renderer\.initialBytes: baseline 2,750,491 bytes has no measurement under journeys\.launch\.counters/
);
const wallClockInCounters = compareToBaselines({
baselines,
summary: {
journeys: {
launch: {
counters: {
'renderer.initialBytes': 2750491,
spawnToFirstCardMs: 1000,
},
},
},
},
});
assert.equal(wallClockInCounters.failures.length, 1);
assert.match(
wallClockInCounters.failures[0],
/spawnToFirstCardMs: baseline 1,000 ms has no measurement under journeys\.launch\.wallClock/
);
});
test('an empty or malformed summary fails every baseline', () => {
assert.equal(
compareToBaselines({ baselines, summary: {} }).failures.length,
2
);
assert.equal(
compareToBaselines({ baselines, summary: null }).failures.length,
2
);
const nonNumeric = compareToBaselines({
baselines,
summary: {
journeys: {
launch: { counters: { 'renderer.initialBytes': '2750491' } },
},
},
});
assert.match(nonNumeric.failures[0], /"2750491" is not a finite number/);
});
test('a measured counter without a baseline is reported but does not fail', () => {
const summary = summaryWith(2750491);
summary.journeys.launch.counters['renderer.cdTicksToFirstCard'] = 12;
summary.journeys.search = { counters: { sqlStatementsPerKeystroke: 3 } };
const result = compareToBaselines({ baselines, summary });
assert.deepEqual(result.failures, []);
assert.deepEqual(
result.unbaselined.map((line) => line.split(':')[0]),
[
'launch/renderer.cdTicksToFirstCard',
'search/sqlStatementsPerKeystroke',
]
);
});
test('an empty baselines file fails instead of passing vacuously', () => {
const result = compareToBaselines({
baselines: { journeys: {} },
summary: summaryWith(2750491),
});
assert.equal(result.failures.length, 1);
assert.match(result.failures[0], /No baselines were checked/);
assert.match(
formatResult(result),
/Journey ratchet failed: 1 of 1 baselines exceeded/
);
});
test('--only restricts the check to the named baselines', () => {
const result = compareToBaselines({
baselines,
summary: {
journeys: {
launch: { counters: { 'renderer.initialBytes': 2750491 } },
},
},
only: ['launch/renderer.initialBytes'],
});
assert.deepEqual(result.failures, []);
assert.equal(result.passed.length, 1);
assert.match(result.passed[0], /launch\/renderer\.initialBytes/);
});
test('--only naming a missing baseline fails rather than checking nothing', () => {
const result = compareToBaselines({
baselines,
summary: summaryWith(2750491),
only: ['launch/does.notExist'],
});
assert.equal(result.failures.length, 2);
assert.match(
result.failures[0],
/launch\/does\.notExist: --only names a baseline that does not exist/
);
assert.match(result.failures[1], /No baselines were checked/);
});
test('rejects malformed baseline files', () => {
assert.throws(() => validateBaselines({}), /object with a "journeys" map/);
assert.throws(
() => validateBaselines({ journeys: { launch: [] } }),
/journey "launch" must map/
);
assert.throws(
() =>
validateBaselines({
journeys: { launch: { x: { value: 'big' } } },
}),
/Baseline launch\/x needs a finite numeric "value"/
);
assert.throws(
() =>
validateBaselines({
journeys: { launch: { x: { value: 1, toleranceRatio: 0.5 } } },
}),
/invalid "toleranceRatio"/
);
});
test('formats a summary line for both outcomes', () => {
const ok = formatResult(
compareToBaselines({ baselines, summary: summaryWith(2600000) })
);
assert.match(ok, /^ok {7}launch\/spawnToFirstCardMs/m);
assert.match(ok, /^tighten {2}launch\/renderer\.initialBytes/m);
assert.match(
ok,
/Journey ratchet OK: 2 baselines checked, 1 can be tightened\.$/
);
const failed = formatResult(
compareToBaselines({ baselines, summary: summaryWith(3000000) })
);
assert.match(failed, /^FAIL {5}launch\/renderer\.initialBytes/m);
assert.match(
failed,
/Journey ratchet failed: 1 of 2 baselines exceeded\.$/
);
});
test('parses CLI arguments and requires --summary', () => {
assert.deepEqual(parseArgs(['--summary', 's.json']), {
summary: 's.json',
baselines: DEFAULT_BASELINES_PATH,
only: [],
});
assert.deepEqual(
parseArgs([
'--',
'--summary=s.json',
'--baselines=b.json',
'--only',
'launch/a',
'--only=search/b',
]),
{
summary: 's.json',
baselines: 'b.json',
only: ['launch/a', 'search/b'],
}
);
assert.throws(
() => parseArgs(['--summary', 's', '--only', 'launch']),
/--only expects <journey>\/<counter>/
);
assert.throws(
() => parseArgs(['--summary', 's', '--only']),
/Missing value for --only/
);
assert.throws(
() => parseArgs([]),
/--summary <journey-summary\.json> is required/
);
assert.throws(
() => parseArgs(['--summary']),
/Missing value for --summary/
);
assert.throws(
() => parseArgs(['--summary', 's', '--strict']),
/Unknown argument: --strict/
);
});
test('the committed baselines file is valid and every entry names its evidence fields', async () => {
const committed = JSON.parse(
await readFile(committedBaselinesPath, 'utf8')
);
validateBaselines(committed);
assert.ok(
committed.journeys.launch?.['renderer.initialBytes'],
'the launch/renderer.initialBytes baseline must stay committed; the CI ratchet guards it'
);
for (const entries of Object.values(committed.journeys)) {
for (const entry of Object.values(entries)) {
assert.match(entry.updatedAt, /^\d{4}-\d{2}-\d{2}$/);
assert.ok(
'evidencePr' in entry,
'evidencePr must be present (null before the first PR)'
);
assert.equal(typeof entry.measuredWith, 'string');
}
}
});
async function runCli(summary, extraBaselines = baselines) {
const summaryPath = path.join(
workDir,
`summary-${Date.now()}-${Math.random()}.json`
);
const baselinesPath = path.join(
workDir,
`baselines-${Date.now()}-${Math.random()}.json`
);
await writeFile(summaryPath, JSON.stringify(summary));
await writeFile(baselinesPath, JSON.stringify(extraBaselines));
return spawnSync(
process.execPath,
[scriptPath, '--summary', summaryPath, '--baselines', baselinesPath],
{ encoding: 'utf8' }
);
}
test('CLI exits 0 when within baselines and 1 when a counter grew', async () => {
const ok = await runCli(summaryWith(2750491));
assert.equal(ok.status, 0, ok.stderr);
assert.match(ok.stdout, /Journey ratchet OK/);
const grew = await runCli(summaryWith(2750492));
assert.equal(grew.status, 1);
assert.match(grew.stderr, /FAIL {5}launch\/renderer\.initialBytes/);
});
test('CLI exits 1 with a readable message when the summary is missing', () => {
const result = spawnSync(
process.execPath,
[scriptPath, '--summary', path.join(workDir, 'nope.json')],
{ encoding: 'utf8' }
);
assert.equal(result.status, 1);
assert.match(
result.stderr,
/check-journey-ratchet: Cannot read journey summary at/
);
});