chore(performance): measure initial bytes of the built web app (#1692)

First step of the performance-journeys ratchet (plan thread: J1 `launch`, counter `renderer.initialBytes`).

- `tools/performance/measure-initial-bytes.mjs` reads the built `dist/apps/web/index.html` and sums `index.html` plus every same-origin `<script src>`, `<link rel="stylesheet">` and `<link rel="modulepreload">` it references. Manifest, icons, external URLs and lazy chunks are not counted. A referenced file missing from the build fails the measurement instead of counting as zero bytes.
- `--json` prints the breakdown; `--summary <file>` writes the `journeys.<journey>.counters` shape a ratchet checker will consume (next PR).
- New Nx project `performance-tools` (test + lint targets), Tier B in `tools/coverage/coverage-policy.json`, root scripts `perf:initial-bytes` and `perf:tools:test`.
- New contract `docs/architecture/performance-journeys.md`, linked from the validation map, the agent context map and the README.
- **Review follow-ups:** resources are deduplicated by request URL (query kept, fragment dropped); `index.html` is parsed with parse5 (already a repository dependency, scripting enabled), so comments, bogus comments, raw-text bodies (script/style/noscript/title/textarea), inert `<template>` contents and character references in attributes all follow the HTML5 algorithm instead of a hand-written scanner; the review's edge cases stay as regression tests; docs show the `pnpm --silent` form for JSON output and explain how the counter relates to Angular's rounded "Initial total".
- **Found while measuring:** the environment files and the playback diagnostic panel imported the whole `package.json` (`import packageJson from '@package'`), which esbuild cannot tree-shake, so `main.js` carried the complete file and the counter moved with every script or dependency edit. They now import `{ version }` only (eb662c485): `main.js` shrinks by **11,539 bytes** and the counter no longer depends on `package.json`. Jest's ESM loader exposes JSON only as a default export, so the two web Jest configs map `@package` to a stub that serves the real file's fields as named exports. Release note: `.changes/web-version-only-from-package-json.md` (`type: perf`).

Production build after this PR measures **2,739,508 bytes** (11 files + `index.html`); Angular's "Initial total" is this minus `index.html` and `assets/app-config.js`. The baseline file and the CI check land in the follow-up PRs; C1 (lazy Angular date locales) then lowers it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Fable 5.1 authored and GitHub committed 2026-09-26 13:31:27 +02:00
1 parent 3ed612ba65
commit 8bc877b625
18 files changed
+777 -10

No files matched your search

@@ -0,0 +1,7 @@
---
type: perf
area: web
---
The app no longer ships its whole `package.json` inside the startup bundle,
only its version number, which trims about 11 KB from every launch.
+11
View File
@@ -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 the performance ratchet guards), 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.**
+1
View File
@@ -39,6 +39,7 @@ export default {
tslib: 'tslib/tslib.es6.js',
'^iptv-playlist-parser$':
'<rootDir>/src/test-stubs/iptv-playlist-parser.mjs',
'^@package$': '<rootDir>/src/test-stubs/package.mjs',
'^shaka-player$': '<rootDir>/src/test-stubs/shaka-player.js',
'^video.js$': '<rootDir>/src/test-stubs/video-js.js',
'^rxjs': '<rootDir>/../../node_modules/rxjs/dist/bundles/rxjs.umd.js',
+2 -2
View File
@@ -3,11 +3,11 @@
// `ng build --env=prod` then `index.prod.ts` will be used instead.
// The list of which env maps to which file can be found in `.angular-cli.json`.
import packageJson from '@package';
import { version as appVersion } from '@package';
export const AppConfig = {
production: false,
environment: 'DEV',
version: packageJson.version,
version: appVersion,
BACKEND_URL: 'http://localhost:3000',
};
@@ -1,8 +1,8 @@
import packageJson from '@package';
import { version as appVersion } from '@package';
export const AppConfig = {
production: true,
environment: 'PROD',
version: packageJson.version,
version: appVersion,
BACKEND_URL: 'https://iptvnator-playlist-parser-api.vercel.app',
};
+2 -2
View File
@@ -1,8 +1,8 @@
import packageJson from '@package';
import { version as appVersion } from '@package';
export const AppConfig = {
production: false,
environment: 'LOCAL',
version: packageJson.version,
version: appVersion,
BACKEND_URL: 'http://localhost:3000',
};
+2 -2
View File
@@ -1,8 +1,8 @@
import packageJson from '@package';
import { version as appVersion } from '@package';
export const AppConfig = {
production: false,
environment: 'WEB',
version: packageJson.version,
version: appVersion,
BACKEND_URL: 'http://localhost:3333',
};
+12
View File
@@ -0,0 +1,12 @@
// Jest's ESM loader exposes a JSON module only as a default export, while the
// app imports `{ version }` from '@package' so esbuild can tree-shake the rest
// of package.json out of the bundle. This stub serves the real file's fields
// as named exports for tests.
import { readFileSync } from 'node:fs';
const packageJson = JSON.parse(
readFileSync(new URL('../../../../package.json', import.meta.url), 'utf8')
);
export const version = packageJson.version;
export default packageJson;
+68
View File
@@ -0,0 +1,68 @@
# 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 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
| 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 `<script src>`, including `assets/app-config.js`,
- every `<link rel="stylesheet">`,
- every `<link rel="modulepreload">` chunk.
Manifest, icons, external URLs, commented-out tags and lazy chunks are not
counted. A file that `index.html` references but the build did not emit is an
error, never zero bytes. The value is raw (uncompressed) size, which is what the
renderer parses. It is Angular's "Initial total" plus `index.html` and
`assets/app-config.js` (about 4 KB together), so it sits slightly above the
rounded figure the build prints; never copy that figure into a baseline, use
the script's output. The bundle embeds only the app version from
`package.json` (a named import, which esbuild tree-shakes), not the whole
file, so editing scripts or dependencies does not move the counter.
```bash
pnpm nx build web # production configuration
pnpm run perf:initial-bytes # human-readable breakdown
pnpm --silent run perf:initial-bytes -- --json # machine-readable; --silent keeps pnpm's headers out of stdout
node tools/performance/measure-initial-bytes.mjs --summary dist/performance/journey-summary.json
```
`--summary` writes the journey summary shape (`journeys.<journey>.counters`)
that the ratchet checker consumes. `--dist <dir>` points the script at another
build output, for example the `electron-performance` configuration.
The measurement script is `tools/performance/measure-initial-bytes.mjs`; its
Node tests run with `pnpm nx test performance-tools` (Tier B in the coverage
policy) and lint with `pnpm nx lint performance-tools`.
## Adding a counter
1. Produce the value from the built output or from a deterministic probe, not
from source heuristics. Missing inputs must fail the measurement.
2. Emit it under `journeys.<journey>.counters.<name>` in the summary JSON.
3. Cover the extraction and the failure modes with `node --test` and register
the test file in `tools/performance/project.json`.
4. Validate the counter before it becomes a guardrail: one PR must show that
lowering it moved wall-clock in the same journey.
+13
View File
@@ -153,6 +153,19 @@ Identical English fallback values are reported as warnings by default; use
`node tools/i18n/check-drift.mjs --fail-on-identical` for a stricter translation
audit.
## Performance
```bash
pnpm nx build web
pnpm run perf:initial-bytes
pnpm nx test performance-tools
```
`perf:initial-bytes` reads the built `dist/apps/web/index.html` and sums the
bytes on the initial path (the J1 counter `renderer.initialBytes`). The
contract, what counts and how to add a counter are in the
[performance journeys](performance-journeys.md) document.
## Logging
Runtime playback and EPG debug logs should use the existing logger or trace
+1
View File
@@ -14,6 +14,7 @@ are not prerequisites for reading repository contracts.
| Bootstrap, project placement, dependencies, aliases and lint configuration; root Nx config and project-local project.json files | [Nx boundaries](../architecture/nx-workspace-boundaries.md), [security overrides](../architecture/dependency-security-overrides.md) | [Nx architecture](../../.codex/skills/iptvnator-nx-architecture/SKILL.md) |
| Angular conventions; docs and skills maintenance | [Agent workflow](../development/agent-workflow.md) | Use the area's skill below |
| Unit, E2E, lint and coverage; `tools/coverage` | [Validation map](../architecture/validation-map.md) | Use the area's validation section |
| Performance journeys, counters and the CI ratchet; `tools/performance` | [Performance journeys](../architecture/performance-journeys.md) | Read the contract directly |
| Electron entry/events/preload and CDP; `apps/electron-backend` | [Debugging and trace flags](../development/electron-debugging.md), [Electron security](../architecture/electron-security.md) | Use the available global electron skill for automation |
| Releases, notes, screenshots, native assets, Linux manager metadata; `tools/release` | [Release pipeline](../architecture/release-pipeline.md), [note format](../../.changes/README.md) | [Release notes](../../.codex/skills/release-notes/SKILL.md), [release cut](../../.codex/skills/release-cut/SKILL.md) |
+1
View File
@@ -37,6 +37,7 @@ export default {
tslib: 'tslib/tslib.es6.js',
'^iptv-playlist-parser$':
'<rootDir>/apps/web/src/test-stubs/iptv-playlist-parser.mjs',
'^@package$': '<rootDir>/apps/web/src/test-stubs/package.mjs',
'^shaka-player$': '<rootDir>/apps/web/src/test-stubs/shaka-player.js',
'^rxjs': '<rootDir>/node_modules/rxjs/dist/bundles/rxjs.umd.js',
'^uuid$': '<rootDir>/node_modules/uuid/wrapper.mjs',
@@ -1,5 +1,5 @@
import { DOCUMENT } from '@angular/common';
import packageJson from '@package';
import { version as appVersion } from '@package';
import { createDiagnosticReport } from './playback-diagnostic-report.util';
import { ClipboardModule } from '@angular/cdk/clipboard';
import {
@@ -91,7 +91,7 @@ export class PlaybackDiagnosticPanelComponent {
readonly diagnosticReport = computed(() =>
createDiagnosticReport(
this.diagnostic(),
packageJson.version,
appVersion,
this.document.defaultView?.navigator.userAgent ?? ''
)
);
+2
View File
@@ -71,6 +71,8 @@
"serve:website": "nx serve website",
"build:website": "nx build website",
"i18n:check": "node tools/i18n/check-drift.mjs",
"perf:initial-bytes": "node tools/performance/measure-initial-bytes.mjs",
"perf:tools:test": "node --test tools/performance/measure-initial-bytes.test.mjs",
"agents:validate": "node tools/skills/validate-agent-guidance.mjs",
"skills:validate": "node tools/skills/validate-repository-skills.mjs",
"release:artwork:dry-run": "tsx --tsconfig tsconfig.base.json tools/release/generate-marketing-artwork.ts --dry-run",
+6
View File
@@ -317,6 +317,12 @@
"validationCommand": "pnpm nx test eslint-tools",
"reason": "Node tests assert the committed max-lines baseline still matches what the generator produces; the scripts are lint tooling, not shipped source, and percentage coverage over a generated list would not mean anything."
},
{
"name": "performance-tools",
"root": "tools/performance",
"validationCommand": "pnpm nx test performance-tools",
"reason": "Node tests validate the initial-bytes measurement over synthetic build output; the scripts are performance tooling, not shipped source."
},
{
"name": "shared-marketing-fixtures",
"root": "libs/shared/marketing-fixtures",
+269
View File
@@ -0,0 +1,269 @@
/**
* Measures the bytes a browser fetches before the Angular app can bootstrap:
* `index.html` itself plus every same-origin script, stylesheet and
* `modulepreload` chunk it references. This is the J1 ("launch to usable")
* counter `renderer.initialBytes` from docs/architecture/performance-journeys.md.
*
* The number is read from the built output, not estimated from source, so it
* is deterministic for a given build and can be ratcheted in CI.
*
* Usage:
* node tools/performance/measure-initial-bytes.mjs [--dist dist/apps/web]
* [--json] [--summary dist/performance/journey-summary.json]
*/
import { existsSync } from 'node:fs';
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { parse } from 'parse5';
export const DEFAULT_DIST_DIR = 'dist/apps/web';
export const INITIAL_BYTES_COUNTER = 'renderer.initialBytes';
export const LAUNCH_JOURNEY = 'launch';
const EXTERNAL_URL = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;
const HTML_NAMESPACE = 'http://www.w3.org/1999/xhtml';
/**
* A resource only counts when the browser fetches it on the initial path from
* the same origin. Manifest, icons and external URLs are not part of the
* payload the ratchet guards: icons load lazily, and external hosts are
* outside the build's control.
*/
function classify(tag, attributes) {
if (tag === 'script') {
return attributes.src ? { kind: 'script', url: attributes.src } : null;
}
const rel = (attributes.rel ?? '').toLowerCase().split(/\s+/);
if (!attributes.href) return null;
if (rel.includes('stylesheet')) {
return { kind: 'stylesheet', url: attributes.href };
}
if (rel.includes('modulepreload')) {
return { kind: 'modulepreload', url: attributes.href };
}
return null;
}
/**
* Yields the `script` and `link` elements the browser actually creates, in
* document order, by parsing `index.html` with the same HTML5 algorithm the
* browser uses (parse5, scripting enabled): comments, doctype and bogus
* comments are not elements, script/style/noscript/textarea/title bodies are
* text, `<template>` contents are inert and live in a separate fragment,
* attribute values arrive with character references decoded, and an SVG
* `<script>` inside inline SVG is in another namespace (it uses `href`, and
* the HTML parser does not fetch it), while HTML inside `<foreignObject>`
* is back in the HTML namespace.
*/
export function scanLiveTags(html) {
const tags = [];
const visit = (node) => {
for (const child of node.childNodes ?? []) {
if (
child.namespaceURI === HTML_NAMESPACE &&
(child.nodeName === 'script' || child.nodeName === 'link')
) {
tags.push({
tag: child.nodeName,
attributes: Object.fromEntries(
child.attrs.map((attribute) => [
attribute.name.toLowerCase(),
attribute.value,
])
),
});
}
visit(child);
}
};
visit(parse(html, { scriptingEnabled: true }));
return tags;
}
/**
* Lists the same-origin resources `index.html` puts on the initial path, in
* 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();
const resources = [];
for (const { tag, attributes } of scanLiveTags(html)) {
const resource = classify(tag, attributes);
if (!resource || EXTERNAL_URL.test(resource.url)) continue;
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;
}
async function sizeOf(filePath) {
const stats = await stat(filePath);
return stats.size;
}
/**
* Reads the built output and returns the per-file breakdown plus the counter
* value. Missing files are an error rather than zero bytes: a broken reference
* would otherwise look like a bundle-size win.
*/
export async function measureInitialBytes({ distDir, readSize = sizeOf }) {
const indexPath = path.join(distDir, 'index.html');
if (!existsSync(indexPath)) {
throw new Error(
`No index.html under ${distDir}. Build the web app first (pnpm nx build web).`
);
}
const html = await readFile(indexPath, 'utf8');
const indexBytes = await readSize(indexPath);
const resources = [];
const missing = [];
for (const resource of extractInitialResources(html)) {
const absolute = path.join(distDir, resource.path);
if (!existsSync(absolute)) {
missing.push(resource.path);
continue;
}
resources.push({ ...resource, bytes: await readSize(absolute) });
}
if (missing.length > 0) {
throw new Error(
`index.html references files that are not in ${distDir}: ${missing.join(', ')}`
);
}
const totals = {
indexHtml: indexBytes,
script: 0,
stylesheet: 0,
modulepreload: 0,
};
for (const resource of resources) totals[resource.kind] += resource.bytes;
const initialBytes =
totals.indexHtml +
totals.script +
totals.stylesheet +
totals.modulepreload;
return {
distDir,
indexHtml: { path: 'index.html', bytes: indexBytes },
resources,
totals: { ...totals, initialBytes },
counters: { [INITIAL_BYTES_COUNTER]: initialBytes },
};
}
/** The journey summary shape consumed by check-journey-ratchet.mjs. */
export function toJourneySummary(
measurement,
{ measuredAt = new Date() } = {}
) {
return {
version: 1,
measuredAt: measuredAt.toISOString(),
journeys: {
[LAUNCH_JOURNEY]: {
counters: { ...measurement.counters },
},
},
};
}
function formatBytes(bytes) {
return bytes.toLocaleString('en-US');
}
export function formatReport(measurement) {
const rows = [...measurement.resources].sort((a, b) => b.bytes - a.bytes);
const width = Math.max(
...rows.map((row) => row.url.length),
'index.html'.length
);
const lines = [
`Initial payload of ${measurement.distDir}`,
'',
`${'index.html'.padEnd(width)} html ${formatBytes(measurement.indexHtml.bytes).padStart(11)}`,
...rows.map(
(row) =>
`${row.url.padEnd(width)} ${row.kind.padEnd(13)} ${formatBytes(row.bytes).padStart(11)}`
),
'',
`scripts ${formatBytes(measurement.totals.script).padStart(11)}`,
`stylesheets ${formatBytes(measurement.totals.stylesheet).padStart(11)}`,
`modulepreload ${formatBytes(measurement.totals.modulepreload).padStart(11)}`,
`${INITIAL_BYTES_COUNTER} = ${formatBytes(measurement.totals.initialBytes)} bytes (${measurement.resources.length} files + index.html)`,
];
return lines.join('\n');
}
export function parseArgs(argv) {
const options = { distDir: DEFAULT_DIST_DIR, json: false, summary: null };
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
if (argument === '--') continue;
if (argument === '--json') {
options.json = true;
} else if (argument === '--dist') {
options.distDir = argv[++index];
} else if (argument.startsWith('--dist=')) {
options.distDir = argument.slice('--dist='.length);
} else if (argument === '--summary') {
options.summary = argv[++index];
} else if (argument.startsWith('--summary=')) {
options.summary = argument.slice('--summary='.length);
} else {
throw new Error(`Unknown argument: ${argument}`);
}
if (options.distDir === undefined || options.summary === undefined) {
throw new Error(`Missing value for ${argument}`);
}
}
return options;
}
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 measurement = await measureInitialBytes({
distDir: path.resolve(options.distDir),
});
measurement.distDir =
path.relative(process.cwd(), measurement.distDir) || '.';
if (options.summary) {
const summaryPath = path.resolve(options.summary);
await mkdir(path.dirname(summaryPath), { recursive: true });
await writeFile(
summaryPath,
`${JSON.stringify(toJourneySummary(measurement), null, 4)}\n`
);
}
console.log(
options.json
? JSON.stringify(measurement, null, 4)
: formatReport(measurement)
);
if (options.summary && !options.json) {
console.log(`\nJourney summary written to ${options.summary}`);
}
} catch (error) {
console.error(`measure-initial-bytes: ${error.message}`);
process.exitCode = 1;
}
}
@@ -0,0 +1,345 @@
import assert from 'node:assert/strict';
import { execFileSync, spawnSync } from 'node:child_process';
import { mkdtemp, mkdir, 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_DIST_DIR,
INITIAL_BYTES_COUNTER,
extractInitialResources,
formatReport,
measureInitialBytes,
parseArgs,
scanLiveTags,
toJourneySummary,
} from './measure-initial-bytes.mjs';
const scriptPath = fileURLToPath(
new URL('./measure-initial-bytes.mjs', import.meta.url)
);
/** Mirrors the shape the Angular application builder emits for apps/web. */
const BUILT_INDEX_HTML = `<!doctype html>
<html><head>
<meta charset="utf-8"/>
<link rel="manifest" href="manifest.webmanifest"/>
<link rel="apple-touch-icon" href="assets/icons/apple-touch-icon.png"/>
<link rel="icon" type="image/x-icon" href="assets/icons/favicon.ico"/>
<script src="assets/app-config.js" defer=""></script>
<link rel="stylesheet" href="styles-VDU4SQ5F.css"></head>
<body class="mat-app-background"><app-root></app-root>
<link rel="modulepreload" href="chunk-B6uziQ1i.js"><link rel="modulepreload" href="chunk-Cn2Agfvf.js"><script src="polyfills-EBB6HFCX.js" type="module"></script><script src="main-EI6PCDGR.js" type="module"></script></body></html>`;
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).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', () => {
const html = `
<link rel="manifest" href="manifest.webmanifest">
<link rel="icon" href="favicon.ico">
<link rel="preconnect" href="https://fonts.gstatic.com">
<link rel="stylesheet" href="https://cdn.example.com/theme.css">
<script src="//cdn.example.com/analytics.js"></script>
<script src="data:text/javascript,1"></script>
<script>inline()</script>
<script src="main.js" type="module"></script>`;
assert.deepEqual(extractInitialResources(html), [
{ path: 'main.js', url: 'main.js', kind: 'script' },
]);
});
test('deduplicates by request URL, ignores fragments and normalizes relative URLs', () => {
const html = `
<LINK REL="modulepreload" HREF='./chunk-a.js'>
<link rel="modulepreload" href="chunk-a.js">
<link rel="modulepreload" href="chunk-a.js?v=2">
<link rel="modulepreload" href="/chunk-b.js#hash">
<link rel="modulepreload" href="chunk-b.js#other">
<script src=main.js></script>
<script src="main.js"></script>`;
assert.deepEqual(extractInitialResources(html), [
{ 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('ignores commented-out tags and tag-like text inside inline scripts and styles', () => {
const html = `
<!-- <script src="old.js"></script> -->
<!--
<link rel="stylesheet" href="legacy.css">
-->
<script>const markup = '<script src="fake.js"><\\/script><link rel="modulepreload" href="fake-chunk.js">';</script>
<style>/* <link rel="stylesheet" href="fake.css"> */ body { color: red; }</style>
<script src="assets/app-config.js" defer></script>
<script src="main.js" type="module"></script>`;
assert.deepEqual(
extractInitialResources(html).map((resource) => resource.url),
['assets/app-config.js', 'main.js']
);
assert.deepEqual(
scanLiveTags('<script>1 < 2</script><LINK rel=x>').map((t) => t.tag),
['script', 'link']
);
// '<!' that is not '<!--' opens a bogus comment up to the next '>', so
// the script after it is live, exactly as the HTML tokenizer sees it.
assert.deepEqual(
extractInitialResources(
'<!doctype html><!<!-- a -->-- <script src="x.js"></script> -->'
).map((resource) => resource.url),
['x.js']
);
});
test('a comment opener inside a script body does not swallow later live tags', () => {
const html = `<script>const x='<!--';</script><script src="main.js"></script><!-- real --><link rel="modulepreload" href="chunk.js">`;
assert.deepEqual(
extractInitialResources(html).map((resource) => resource.url),
['main.js', 'chunk.js']
);
const reverse = `<!-- <script>x</script> --><style>a::before{content:'<!--'}</style><script src="live.js"></script>`;
assert.deepEqual(
extractInitialResources(reverse).map((resource) => resource.url),
['live.js']
);
// Unterminated raw text swallows the rest, as it does in a browser.
assert.deepEqual(
extractInitialResources('<script>x<script src="a.js"></script'),
[]
);
});
test('raw text ends only at an exact end tag, and noscript/title bodies are text', () => {
const lookalike = `<script>const a='</scriptlet>', b='<script src="fake.js">';</script><script src="real.js"></script>`;
assert.deepEqual(
extractInitialResources(lookalike).map((resource) => resource.url),
['real.js']
);
const fallback = `<noscript><script src="fallback.js"></script><link rel="stylesheet" href="noscript.css"></noscript><title><script src="t.js"></script></title><textarea><link rel="modulepreload" href="ta.js"></textarea><script src="app.js"></script>`;
assert.deepEqual(
extractInitialResources(fallback).map((resource) => resource.url),
['app.js']
);
assert.deepEqual(
extractInitialResources(
'<script>x</script\t><script src="y.js"></script >'
).map((r) => r.url),
['y.js']
);
});
test('template contents are inert and character references are decoded', () => {
const html = `<template><script src="fallback.js"></script><link rel="stylesheet" href="t.css"></template><script src="chunk.js?a=1&amp;b=2"></script><link rel="modulepreload" href="chunk.js?a=1&b=2"><script src="main.js"></script>`;
assert.deepEqual(
extractInitialResources(html).map((resource) => resource.url),
['chunk.js?a=1&b=2', 'main.js']
);
});
test('SVG script elements are not HTML scripts, HTML inside foreignObject is', () => {
const html = `<svg><script src="icon.js"></script><script href="icon2.js"></script><foreignObject><script src="html-in-svg.js"></script></foreignObject></svg><script src="main.js"></script>`;
assert.deepEqual(
extractInitialResources(html).map((resource) => resource.url),
['html-in-svg.js', 'main.js']
);
});
test('counts a file once per distinct request URL', async () => {
const distDir = await writeDist('cache-busted', {
indexHtml: `<link rel="modulepreload" href="chunk-a.js"><link rel="modulepreload" href="chunk-a.js?v=2">`,
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 });
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 dropped = ['chunk-Cn2Agfvf.js', 'main-EI6PCDGR.js'];
const files = Object.fromEntries(
Object.entries(BUILT_FILES).filter(([file]) => !dropped.includes(file))
);
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/);
});
+31
View File
@@ -0,0 +1,31 @@
{
"$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",
{ "externalDependencies": ["parse5"] }
],
"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\""
}
}
}