From bebd4d3049db0dc3d42942ed83b17f8746f4a4f8 Mon Sep 17 00:00:00 2001 From: 4gray Date: Sat, 26 Sep 2026 07:58:19 +0200 Subject: [PATCH] chore(performance): measure initial bytes of the built web app Add tools/performance/measure-initial-bytes.mjs, which reads the built dist/apps/web/index.html and sums index.html plus every same-origin script, stylesheet and modulepreload chunk it references. The value is the J1 ("launch to usable") counter renderer.initialBytes and matches the "Initial total" line of the Angular build output. Missing referenced files fail the measurement instead of counting as zero bytes. The script has node --test coverage over synthetic build output, its own Nx project (performance-tools, Tier B in the coverage policy), root scripts perf:initial-bytes / perf:tools:test, and a new docs/architecture/performance-journeys.md contract linked from the validation map, the context map and the README. Co-Authored-By: Claude Fable 5.1 --- README.md | 11 + docs/architecture/performance-journeys.md | 61 +++++ docs/architecture/validation-map.md | 13 + docs/maintenance/agent-context-map.md | 1 + package.json | 2 + tools/coverage/coverage-policy.json | 6 + tools/performance/measure-initial-bytes.mjs | 243 +++++++++++++++++ .../measure-initial-bytes.test.mjs | 245 ++++++++++++++++++ tools/performance/project.json | 27 ++ 9 files changed, 609 insertions(+) create mode 100644 docs/architecture/performance-journeys.md create mode 100644 tools/performance/measure-initial-bytes.mjs create mode 100644 tools/performance/measure-initial-bytes.test.mjs create mode 100644 tools/performance/project.json diff --git a/README.md b/README.md index 7a9f69617..d5b713edc 100644 --- a/README.md +++ b/README.md @@ -376,6 +376,17 @@ To run only the Angular app without Electron, use: $ pnpm run serve:frontend ``` +To see how many bytes the built web app puts on the initial load path (the +number CI ratchets), build it and run the measurement: + +``` +$ pnpm nx build web +$ pnpm run perf:initial-bytes +``` + +The contract behind that number is in +[docs/architecture/performance-journeys.md](docs/architecture/performance-journeys.md). + ## Disclaimer **IPTVnator doesn't provide any playlists or other digital content.** diff --git a/docs/architecture/performance-journeys.md b/docs/architecture/performance-journeys.md new file mode 100644 index 000000000..db374796e --- /dev/null +++ b/docs/architecture/performance-journeys.md @@ -0,0 +1,61 @@ +# Performance journeys and the CI ratchet + +IPTVnator measures performance through a small set of everyday user journeys. +Each journey has deterministic counters that are asserted exactly, and +wall-clock timings that are recorded as evidence. Counters are ratcheted in CI: +a committed baseline may only be lowered, and only with the measured output as +evidence. This document is the contract for that loop; `tools/performance/` +holds the scripts. + +## Journeys + +| Journey | Start | End | +| ---------------- | -------------------------------------------- | ----------------------------------------------------------------------------- | +| J1 `launch` | Electron process spawn | first playlist or portal card rendered on `/workspace`, inline splash removed | +| J2 `open-source` | click on a portal card | live category list and first channel page painted | +| J3 `playback` | click on a channel | HTML5 `playing` event | +| J4 `search` | six-character query typed into global search | results list settled | + +Only the J1 counter `renderer.initialBytes` is instrumented today. The other +journeys and counters follow the plan in `.plans/` and are added one thread at +a time; each thread names its journey and counter in the PR description. + +## `renderer.initialBytes` + +The bytes a browser fetches before Angular can bootstrap, read from the built +`dist/apps/web/index.html`: + +- `index.html` itself, +- every same-origin ` + + +`; + +const BUILT_FILES = { + 'assets/app-config.js': 65, + 'styles-VDU4SQ5F.css': 311539, + 'chunk-B6uziQ1i.js': 1566, + 'chunk-Cn2Agfvf.js': 529624, + 'polyfills-EBB6HFCX.js': 35876, + 'main-EI6PCDGR.js': 1131437, +}; + +let workDir; + +async function writeDist( + name, + { indexHtml = BUILT_INDEX_HTML, files = BUILT_FILES } = {} +) { + const distDir = path.join(workDir, name); + await mkdir(distDir, { recursive: true }); + if (indexHtml !== null) { + await writeFile(path.join(distDir, 'index.html'), indexHtml); + } + for (const [file, bytes] of Object.entries(files)) { + const target = path.join(distDir, file); + await mkdir(path.dirname(target), { recursive: true }); + await writeFile(target, 'x'.repeat(bytes)); + } + return distDir; +} + +before(async () => { + workDir = await mkdtemp(path.join(os.tmpdir(), 'measure-initial-bytes-')); +}); + +after(async () => { + await rm(workDir, { recursive: true, force: true }); +}); + +test('extracts scripts, stylesheets and modulepreload chunks in document order', () => { + assert.deepEqual(extractInitialResources(BUILT_INDEX_HTML), [ + { path: 'assets/app-config.js', kind: 'script' }, + { path: 'styles-VDU4SQ5F.css', kind: 'stylesheet' }, + { path: 'chunk-B6uziQ1i.js', kind: 'modulepreload' }, + { path: 'chunk-Cn2Agfvf.js', kind: 'modulepreload' }, + { path: 'polyfills-EBB6HFCX.js', kind: 'script' }, + { path: 'main-EI6PCDGR.js', kind: 'script' }, + ]); +}); + +test('ignores manifest, icon and external references', () => { + const html = ` + + + + + + + + `; + assert.deepEqual(extractInitialResources(html), [ + { path: 'main.js', kind: 'script' }, + ]); +}); + +test('deduplicates references and normalizes relative URLs', () => { + const html = ` + + + + + `; + assert.deepEqual(extractInitialResources(html), [ + { path: 'chunk-a.js', kind: 'modulepreload' }, + { path: 'chunk-b.js', kind: 'modulepreload' }, + { path: 'main.js', kind: 'script' }, + ]); +}); + +test('sums index.html and every referenced file into the counter', async () => { + const distDir = await writeDist('built'); + const measurement = await measureInitialBytes({ distDir }); + + const indexBytes = Buffer.byteLength(BUILT_INDEX_HTML); + const script = 65 + 35876 + 1131437; + const stylesheet = 311539; + const modulepreload = 1566 + 529624; + + assert.deepEqual(measurement.indexHtml, { + path: 'index.html', + bytes: indexBytes, + }); + assert.equal(measurement.resources.length, 6); + assert.deepEqual(measurement.totals, { + indexHtml: indexBytes, + script, + stylesheet, + modulepreload, + initialBytes: indexBytes + script + stylesheet + modulepreload, + }); + assert.deepEqual(measurement.counters, { + [INITIAL_BYTES_COUNTER]: + indexBytes + script + stylesheet + modulepreload, + }); +}); + +test('fails when index.html is missing instead of reporting zero bytes', async () => { + const distDir = await writeDist('no-index', { indexHtml: null, files: {} }); + await assert.rejects( + measureInitialBytes({ distDir }), + /No index\.html under .*no-index.*pnpm nx build web/ + ); +}); + +test('fails and names every referenced file that is missing from the build', async () => { + const { + 'chunk-Cn2Agfvf.js': _dropped, + 'main-EI6PCDGR.js': _alsoDropped, + ...files + } = BUILT_FILES; + const distDir = await writeDist('missing-chunk', { files }); + await assert.rejects( + measureInitialBytes({ distDir }), + /not in .*missing-chunk: chunk-Cn2Agfvf\.js, main-EI6PCDGR\.js/ + ); +}); + +test('journey summary carries the counter under the launch journey', async () => { + const distDir = await writeDist('summary'); + const measurement = await measureInitialBytes({ distDir }); + const summary = toJourneySummary(measurement, { + measuredAt: new Date('2026-09-26T00:00:00.000Z'), + }); + assert.deepEqual(summary, { + version: 1, + measuredAt: '2026-09-26T00:00:00.000Z', + journeys: { + launch: { + counters: { + [INITIAL_BYTES_COUNTER]: measurement.totals.initialBytes, + }, + }, + }, + }); +}); + +test('report lists the largest files first and ends with the counter', async () => { + const distDir = await writeDist('report'); + const report = formatReport(await measureInitialBytes({ distDir })); + const lines = report.split('\n'); + const mainLine = lines.findIndex((line) => + line.startsWith('main-EI6PCDGR.js') + ); + const configLine = lines.findIndex((line) => + line.startsWith('assets/app-config.js') + ); + assert.ok(mainLine > 0 && mainLine < configLine); + assert.match( + lines.at(-1), + /^renderer\.initialBytes = [\d,]+ bytes \(6 files \+ index\.html\)$/ + ); +}); + +test('parses CLI arguments and rejects unknown ones', () => { + assert.deepEqual(parseArgs([]), { + distDir: DEFAULT_DIST_DIR, + json: false, + summary: null, + }); + assert.deepEqual( + parseArgs(['--', '--dist', 'out', '--json', '--summary=s.json']), + { + distDir: 'out', + json: true, + summary: 's.json', + } + ); + assert.deepEqual( + parseArgs(['--dist=out/web', '--summary', 'dist/s.json']).distDir, + 'out/web' + ); + assert.throws( + () => parseArgs(['--verbose']), + /Unknown argument: --verbose/ + ); + assert.throws(() => parseArgs(['--dist']), /Missing value for --dist/); +}); + +test('CLI writes the journey summary and exits 0 on a complete build', async () => { + const distDir = await writeDist('cli'); + const summaryPath = path.join(workDir, 'out', 'journey-summary.json'); + const stdout = execFileSync( + process.execPath, + [scriptPath, '--dist', distDir, '--summary', summaryPath], + { encoding: 'utf8' } + ); + assert.match(stdout, /renderer\.initialBytes = [\d,]+ bytes/); + const summary = JSON.parse(await readFile(summaryPath, 'utf8')); + assert.equal( + summary.journeys.launch.counters[INITIAL_BYTES_COUNTER], + Buffer.byteLength(BUILT_INDEX_HTML) + + Object.values(BUILT_FILES).reduce((sum, bytes) => sum + bytes, 0) + ); +}); + +test('CLI exits 1 with a readable message when the build is missing', () => { + const result = spawnSync( + process.execPath, + [scriptPath, '--dist', path.join(workDir, 'does-not-exist')], + { encoding: 'utf8' } + ); + assert.equal(result.status, 1); + assert.match(result.stderr, /measure-initial-bytes: No index\.html under/); +}); diff --git a/tools/performance/project.json b/tools/performance/project.json new file mode 100644 index 000000000..6bfe11ade --- /dev/null +++ b/tools/performance/project.json @@ -0,0 +1,27 @@ +{ + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "name": "performance-tools", + "projectType": "library", + "sourceRoot": "tools/performance", + "tags": ["scope:tools", "domain:performance", "type:tool"], + "targets": { + "test": { + "executor": "nx:run-commands", + "cache": true, + "inputs": ["{projectRoot}/*.mjs", "{projectRoot}/*.json"], + "options": { + "command": "node --test tools/performance/measure-initial-bytes.test.mjs", + "cwd": "{workspaceRoot}" + } + }, + "lint": { + "inputs": [ + "default", + "{workspaceRoot}/eslint.config.mjs", + "{workspaceRoot}/tools/eslint-rules/**/*", + "{workspaceRoot}/tools/eslint/**/*" + ], + "command": "eslint \"tools/performance/*.mjs\"" + } + } +}