mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-10 10:06:15 -08:00
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
3ed612ba65
commit
bebd4d3049
9 files changed
+609
No files matched your search
@@ -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.**
|
||||
|
||||
@@ -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 `<script src>`, including `assets/app-config.js`,
|
||||
- every `<link rel="stylesheet">`,
|
||||
- every `<link rel="modulepreload">` chunk.
|
||||
|
||||
Manifest, icons, external URLs 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 matches the "Initial total" line of the Angular build output.
|
||||
|
||||
```bash
|
||||
pnpm nx build web # production configuration
|
||||
pnpm run perf:initial-bytes # human-readable breakdown
|
||||
pnpm run perf:initial-bytes -- --json # machine-readable breakdown
|
||||
pnpm run perf:initial-bytes -- --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.
|
||||
@@ -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
|
||||
|
||||
@@ -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) |
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
export const DEFAULT_DIST_DIR = 'dist/apps/web';
|
||||
export const INITIAL_BYTES_COUNTER = 'renderer.initialBytes';
|
||||
export const LAUNCH_JOURNEY = 'launch';
|
||||
|
||||
const TAG_PATTERN = /<(script|link)\b([^>]*)>/gi;
|
||||
const ATTRIBUTE_PATTERN =
|
||||
/([a-zA-Z_:][-a-zA-Z0-9_:.]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
|
||||
const EXTERNAL_URL = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;
|
||||
|
||||
function parseAttributes(raw) {
|
||||
const attributes = {};
|
||||
for (const match of raw.matchAll(ATTRIBUTE_PATTERN)) {
|
||||
const [, name, doubleQuoted, singleQuoted, bare] = match;
|
||||
attributes[name.toLowerCase()] =
|
||||
doubleQuoted ?? singleQuoted ?? bare ?? '';
|
||||
}
|
||||
return attributes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function extractInitialResources(html) {
|
||||
const seen = new Set();
|
||||
const resources = [];
|
||||
for (const [, tag, rawAttributes] of html.matchAll(TAG_PATTERN)) {
|
||||
const resource = classify(
|
||||
tag.toLowerCase(),
|
||||
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 });
|
||||
}
|
||||
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.path.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.path.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,245 @@
|
||||
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,
|
||||
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), [
|
||||
{ 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', kind: 'script' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('deduplicates references and normalizes relative URLs', () => {
|
||||
const html = `
|
||||
<LINK REL="modulepreload" HREF='./chunk-a.js'>
|
||||
<link rel="modulepreload" href="chunk-a.js?v=2">
|
||||
<link rel="modulepreload" href="/chunk-b.js#hash">
|
||||
<script src=main.js></script>
|
||||
<script src="main.js"></script>`;
|
||||
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/);
|
||||
});
|
||||
@@ -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\""
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user