diff --git a/README.md b/README.md index d5b713edc..9e43bd5bc 100644 --- a/README.md +++ b/README.md @@ -377,7 +377,7 @@ $ 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: +number the performance ratchet guards), build it and run the measurement: ``` $ pnpm nx build web diff --git a/docs/architecture/performance-journeys.md b/docs/architecture/performance-journeys.md index db374796e..bde842eb0 100644 --- a/docs/architecture/performance-journeys.md +++ b/docs/architecture/performance-journeys.md @@ -2,10 +2,12 @@ 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. +wall-clock timings that are recorded as evidence. Counters are meant to be +ratcheted in CI: a committed baseline that 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. The measurement script lands first; +the baseline file and the CI job follow in their own PRs (#1693, #1694), so +until they merge the reported number is informational, not enforced. ## Journeys diff --git a/tools/performance/measure-initial-bytes.mjs b/tools/performance/measure-initial-bytes.mjs index 29a2613fb..c3cb6067f 100644 --- a/tools/performance/measure-initial-bytes.mjs +++ b/tools/performance/measure-initial-bytes.mjs @@ -58,8 +58,10 @@ function classify(tag, attributes) { /** * Lists the same-origin resources `index.html` puts on the initial path, in - * document order and without duplicates. Query strings and fragments are - * stripped so the URL maps to a file on disk. + * document order. Duplicates are collapsed by request URL, not by file: the + * browser fetches `chunk.js?v=1` and `chunk.js?v=2` separately, so both count, + * while a fragment never reaches the server and is ignored. `path` is the + * file on disk the URL maps to. */ export function extractInitialResources(html) { const seen = new Set(); @@ -70,10 +72,11 @@ export function extractInitialResources(html) { parseAttributes(rawAttributes) ); if (!resource || EXTERNAL_URL.test(resource.url)) continue; - const file = resource.url.replace(/[?#].*$/, '').replace(/^\.?\//, ''); - if (!file || seen.has(file)) continue; - seen.add(file); - resources.push({ path: file, kind: resource.kind }); + const url = resource.url.replace(/#.*$/, '').replace(/^\.?\//, ''); + const file = url.replace(/\?.*$/, ''); + if (!file || seen.has(url)) continue; + seen.add(url); + resources.push({ path: file, url, kind: resource.kind }); } return resources; } @@ -160,7 +163,7 @@ function formatBytes(bytes) { export function formatReport(measurement) { const rows = [...measurement.resources].sort((a, b) => b.bytes - a.bytes); const width = Math.max( - ...rows.map((row) => row.path.length), + ...rows.map((row) => row.url.length), 'index.html'.length ); const lines = [ @@ -169,7 +172,7 @@ export function formatReport(measurement) { `${'index.html'.padEnd(width)} html ${formatBytes(measurement.indexHtml.bytes).padStart(11)}`, ...rows.map( (row) => - `${row.path.padEnd(width)} ${row.kind.padEnd(13)} ${formatBytes(row.bytes).padStart(11)}` + `${row.url.padEnd(width)} ${row.kind.padEnd(13)} ${formatBytes(row.bytes).padStart(11)}` ), '', `scripts ${formatBytes(measurement.totals.script).padStart(11)}`, diff --git a/tools/performance/measure-initial-bytes.test.mjs b/tools/performance/measure-initial-bytes.test.mjs index 041708013..35dd70d83 100644 --- a/tools/performance/measure-initial-bytes.test.mjs +++ b/tools/performance/measure-initial-bytes.test.mjs @@ -69,14 +69,20 @@ after(async () => { }); 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' }, - ]); + assert.deepEqual( + extractInitialResources(BUILT_INDEX_HTML).map(({ path, kind }) => ({ + path, + kind, + })), + [ + { 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', () => { @@ -90,24 +96,37 @@ test('ignores manifest, icon and external references', () => { `; assert.deepEqual(extractInitialResources(html), [ - { path: 'main.js', kind: 'script' }, + { path: 'main.js', url: 'main.js', kind: 'script' }, ]); }); -test('deduplicates references and normalizes relative URLs', () => { +test('deduplicates by request URL, ignores fragments 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' }, + { path: 'chunk-a.js', url: 'chunk-a.js', kind: 'modulepreload' }, + { path: 'chunk-a.js', url: 'chunk-a.js?v=2', kind: 'modulepreload' }, + { path: 'chunk-b.js', url: 'chunk-b.js', kind: 'modulepreload' }, + { path: 'main.js', url: 'main.js', kind: 'script' }, ]); }); +test('counts a file once per distinct request URL', async () => { + const distDir = await writeDist('cache-busted', { + indexHtml: ``, + files: { 'chunk-a.js': 100 }, + }); + const measurement = await measureInitialBytes({ distDir }); + assert.equal(measurement.resources.length, 2); + assert.equal(measurement.totals.modulepreload, 200); +}); + test('sums index.html and every referenced file into the counter', async () => { const distDir = await writeDist('built'); const measurement = await measureInitialBytes({ distDir });