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 = ` `; 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 = ` `; 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 = ` `; 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 = ` `; assert.deepEqual( extractInitialResources(html).map((resource) => resource.url), ['assets/app-config.js', 'main.js'] ); assert.deepEqual( scanLiveTags('').map((t) => t.tag), ['script', 'link'] ); // '', so // the script after it is live, exactly as the HTML tokenizer sees it. assert.deepEqual( extractInitialResources( '-- -->' ).map((resource) => resource.url), ['x.js'] ); }); test('a comment opener inside a script body does not swallow later live tags', () => { const html = ``; assert.deepEqual( extractInitialResources(html).map((resource) => resource.url), ['main.js', 'chunk.js'] ); const reverse = ``; 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('`; assert.deepEqual( extractInitialResources(lookalike).map((resource) => resource.url), ['real.js'] ); const fallback = `<script src="t.js"></script>`; assert.deepEqual( extractInitialResources(fallback).map((resource) => resource.url), ['app.js'] ); assert.deepEqual( extractInitialResources( '' ).map((r) => r.url), ['y.js'] ); }); test('template contents are inert and character references are decoded', () => { const html = ``; 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 = ``; 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: ``, 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/); });