mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
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>
346 lines
13 KiB
JavaScript
346 lines
13 KiB
JavaScript
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&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/);
|
|
});
|