mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 06:06:18 -08:00
* perf(web): take the Material snack-bar off the startup path The seven services and the app shell created with the app injected MatSnackBar, and snack-bar.mjs statically imports Material's button, the icon button chunk, ripple and the CDK a11y module, so about 110 KB sat in the initial bundle although a toast shows only after an event. A root LazySnackBar (@iptvnator/services/lazy-snack-bar) resolves the snack-bar from a dynamic import the way LazyMatDialog does, retrying after a failed chunk load, and opens text toasts with MatSnackBar.open's arguments; a failed load is logged without the message. The eager importers open their toasts through it with unchanged messages, actions, durations and panel classes; lazy code keeps injecting MatSnackBar. The startup status defers its Retry button, the other eager importer of the button chunk, with a native button as the error fallback. Local production build: renderer.initialBytes 1,349,048 -> 1,236,918 (-112,130, -8.3%); no Material module is in the initial bundle any more. The CDK overlay, scrolling and portal chunks (about 78 KB) stay through the FullscreenOverlayContainer provider in app.config.ts. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(web): keep a native Retry while the startup button chunk loads Greptile (local round 1): the failure screen had no Retry button until the deferred Material button chunk arrived. One native button, drawn in the flat button's shape with theme colours, is now the placeholder and the error view of the defer block, so a retry is possible while the chunk downloads and after a failed load. Baseline and docs follow the re-measured value, 1,237,440 bytes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(perf): point the initialBytes evidence at the opened pull request The baseline and the performance doc named the predicted number; the pull request opened as #1939. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(web): cover the toast actions behind the lazy snack-bar Greptile (PR round 1): the Refresh and Trust-host actions now run after LazySnackBar.open resolves, and no test exercised them. The PWA update toast test holds the toast promise, then resolves it and picks Refresh; the page reload is a field the spec substitutes, as jsdom's location cannot be spied on. The Electron test does the same for the TLS trust toast: the confirmation opens only once the toast is up, approving it records the host and retries the fetch. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(web): type the stubbed toast ref as Observable<undefined> CI's spec typecheck rejected `typeof EMPTY` (Observable<never>) for a stub that returns `of(undefined)`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * perf(web): lower the initialBytes baseline to the merged measurement Merged with #1938 the branch measures 1,231,455 bytes on a local production build (the radio play control's fab tokens left the stylesheet); the baseline follows it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * perf(web): lower the initialBytes baseline to the zoneless merged measurement Merged with #1929, #1936 and #1938 the branch measures 1,178,109 bytes on a local production build; the baseline follows it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * perf(web): re-measure initialBytes after merging #1903 and #1942 Merged with #1903 (self-hosted server storage, which raised master's baseline by 7,245 bytes on purpose) and #1942 the branch measures 1,187,983 bytes on a local production build, below master's 1,285,523; no Material module is in the initial bundle. The new favorites effect spec provides the LazySnackBar stub the merged effect injects. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * perf(xtream): open favorites toasts through LazySnackBar #1934 gave the Xtream store's favorites feature a static MatSnackBar injection. The store exists from startup, so that one import put Material's snack-bar, button, icon-button and ripple chunks (about 95 KB) back on the renderer's initial path. The feature now opens its two toasts through LazySnackBar with the same messages and durations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(perf): count statically imported chunks in renderer.initialBytes Angular's builder emits at most ten modulepreload hints. Once the startup graph passed ten chunks, the rest were fetched through main.js's own imports but never counted, so the counter stopped growing and an eager MatSnackBar import on master went unnoticed. The script now follows the static import graph of every counted script (dynamic import() targets stay lazy; a missing imported file is an error), which matches the bundler's metafile closure exactly: 1,196,205 bytes for this branch, where the old counter read 1,098,535. The baseline follows the new measurement and its note now explains the current limit (Greptile). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(perf): parse startup imports and count them per request URL Codex (PR round): the regexes also matched import-shaped text in strings, templates and comments, which could count a lazy chunk or fail on a file that does not exist. Static declarations are now read from TypeScript's syntax tree (top-level import/export-from statements only). Imports are also deduplicated by request URL, like the index.html references, so a preload of chunk.js?v=1 no longer hides an import of ./chunk.js. The real build still measures 1,196,205 bytes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
459 lines
18 KiB
JavaScript
459 lines
18 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,
|
|
extractStaticImports,
|
|
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;
|
|
}
|
|
|
|
let GRAPH_FILES = {};
|
|
|
|
/** Writes files with real contents, for tests that read module imports. */
|
|
async function workDirWith(name, files) {
|
|
GRAPH_FILES = files;
|
|
const distDir = path.join(workDir, name);
|
|
await mkdir(distDir, { recursive: true });
|
|
for (const [file, contents] of Object.entries(files)) {
|
|
await writeFile(path.join(distDir, file), contents);
|
|
}
|
|
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,
|
|
staticImport: 0,
|
|
initialBytes: indexBytes + script + stylesheet + modulepreload,
|
|
});
|
|
assert.deepEqual(measurement.counters, {
|
|
[INITIAL_BYTES_COUNTER]:
|
|
indexBytes + script + stylesheet + modulepreload,
|
|
});
|
|
});
|
|
|
|
test('extracts static import and re-export specifiers, not dynamic imports', () => {
|
|
const source =
|
|
'import{a as b}from"./chunk-A.js";import"./chunk-B.js";' +
|
|
'export{c}from"./sub/chunk-C.js";export*from"../chunk-D.js";' +
|
|
'const l=()=>import("./chunk-LAZY.js");import"@angular/core";' +
|
|
"const t=\"from './not-a-file.css'\";import\n { e } from './chunk-E.mjs';";
|
|
assert.deepEqual(extractStaticImports(source).sort(), [
|
|
'../chunk-D.js',
|
|
'./chunk-A.js',
|
|
'./chunk-B.js',
|
|
'./chunk-E.mjs',
|
|
'./sub/chunk-C.js',
|
|
]);
|
|
});
|
|
|
|
test('counts chunks that only a static import names, beyond the preload hints', async () => {
|
|
// Angular emits at most ten modulepreload hints; main imports the rest.
|
|
const distDir = await workDirWith('import-graph', {
|
|
'index.html':
|
|
'<link rel="modulepreload" href="chunk-hinted.js"><script src="main.js" type="module"></script>',
|
|
'main.js':
|
|
'import"./chunk-hinted.js";import{x}from"./chunk-unhinted.js";const lazy=()=>import("./chunk-lazy.js");',
|
|
'chunk-hinted.js': 'export const h=1;',
|
|
'chunk-unhinted.js': 'import"./chunk-deep.js";export const x=1;',
|
|
'chunk-deep.js': 'export const d=1;',
|
|
'chunk-lazy.js': 'export const never=1;',
|
|
});
|
|
const measurement = await measureInitialBytes({ distDir });
|
|
const counted = Object.fromEntries(
|
|
measurement.resources.map((resource) => [resource.path, resource.kind])
|
|
);
|
|
assert.deepEqual(counted, {
|
|
'chunk-hinted.js': 'modulepreload',
|
|
'main.js': 'script',
|
|
'chunk-unhinted.js': 'staticImport',
|
|
'chunk-deep.js': 'staticImport',
|
|
});
|
|
const size = (file) => Buffer.byteLength(GRAPH_FILES[file]);
|
|
assert.equal(
|
|
measurement.totals.staticImport,
|
|
size('chunk-unhinted.js') + size('chunk-deep.js')
|
|
);
|
|
});
|
|
|
|
test('import-like text in strings, templates and comments never counts', async () => {
|
|
const distDir = await workDirWith('import-lookalikes', {
|
|
'index.html': '<script src="main.js" type="module"></script>',
|
|
'main.js': [
|
|
'const a=\'import "./chunk-string.js"\';',
|
|
'const b=`export{x}from"./chunk-template.js"`;',
|
|
'/* import"./chunk-comment.js" */',
|
|
'// import { y } from "./chunk-line-comment.js";',
|
|
'export const main=1;',
|
|
].join('\n'),
|
|
});
|
|
const measurement = await measureInitialBytes({ distDir });
|
|
assert.equal(measurement.totals.staticImport, 0);
|
|
assert.deepEqual(
|
|
measurement.resources.map((resource) => resource.path),
|
|
['main.js']
|
|
);
|
|
});
|
|
|
|
test('counts an import once per request URL, like index.html references', async () => {
|
|
// The preload and the import name the same file under different URLs,
|
|
// so the browser fetches two modules.
|
|
const distDir = await workDirWith('url-dedupe', {
|
|
'index.html':
|
|
'<link rel="modulepreload" href="chunk-a.js?v=1"><script src="main.js" type="module"></script>',
|
|
'main.js': 'import"./chunk-a.js";import"./chunk-a.js";',
|
|
'chunk-a.js': 'export const a=1;',
|
|
});
|
|
const measurement = await measureInitialBytes({ distDir });
|
|
assert.deepEqual(
|
|
measurement.resources.map(({ url, kind }) => [url, kind]),
|
|
[
|
|
['chunk-a.js?v=1', 'modulepreload'],
|
|
['main.js', 'script'],
|
|
['chunk-a.js', 'staticImport'],
|
|
]
|
|
);
|
|
assert.equal(
|
|
measurement.totals.staticImport,
|
|
Buffer.byteLength(GRAPH_FILES['chunk-a.js'])
|
|
);
|
|
});
|
|
|
|
test('fails when a startup module imports a file the build did not emit', async () => {
|
|
const distDir = await workDirWith('broken-import', {
|
|
'index.html': '<script src="main.js" type="module"></script>',
|
|
'main.js': 'import"./chunk-gone.js";',
|
|
});
|
|
await assert.rejects(
|
|
measureInitialBytes({ distDir }),
|
|
/import files that are not in .*broken-import: chunk-gone\.js \(imported by main\.js\)/
|
|
);
|
|
});
|
|
|
|
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/);
|
|
});
|