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 });