Merge origin/master into claude/bold-napier-17a16f
Resolves conflicts with #1692-#1695: keeps both perf scripts, both README paragraphs, one context-map row, and merges the J1 launch sections into the performance-journeys document next to the renderer.initialBytes contract. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
No files matched your search
@@ -0,0 +1,8 @@
|
||||
---
|
||||
type: perf
|
||||
area: web
|
||||
---
|
||||
|
||||
The app now loads date and time formatting data only for the language you
|
||||
use instead of shipping all 18 languages in the startup bundle, so it has
|
||||
less to download and parse on every launch; English needs none at all.
|
||||
@@ -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.
|
||||
@@ -126,6 +126,85 @@ jobs:
|
||||
CI: true
|
||||
NX_TASKS_RUNNER_DYNAMIC_OUTPUT: false
|
||||
|
||||
initial-bytes-ratchet:
|
||||
name: Initial bytes ratchet
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v6.0.10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# The ratchet below compares a measurement with the baselines file
|
||||
# of the same commit, so it cannot see a change that grows the
|
||||
# payload and raises the baseline to match. Compare the file with
|
||||
# the revision this one is measured against instead: the target
|
||||
# branch for a pull request, the previous head for a master push,
|
||||
# master for a manual dispatch. Any raised limit, widened tolerance
|
||||
# or removed entry fails.
|
||||
- name: Refuse baseline increases against the previous revision
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
BASE_REF: ${{ github.base_ref }}
|
||||
BEFORE_SHA: ${{ github.event.before }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "$EVENT_NAME" in
|
||||
pull_request)
|
||||
git fetch --no-tags --depth=1 origin "$BASE_REF"
|
||||
;;
|
||||
push)
|
||||
if [ -z "$BEFORE_SHA" ] || [ "$BEFORE_SHA" = "0000000000000000000000000000000000000000" ]; then
|
||||
echo "No previous revision for this push; nothing to compare against."
|
||||
exit 0
|
||||
fi
|
||||
git fetch --no-tags --depth=1 origin "$BEFORE_SHA"
|
||||
;;
|
||||
*)
|
||||
git fetch --no-tags --depth=1 origin master
|
||||
;;
|
||||
esac
|
||||
git show "FETCH_HEAD:tools/performance/journey-baselines.json" > /tmp/base-journey-baselines.json 2>/dev/null ||
|
||||
rm -f /tmp/base-journey-baselines.json
|
||||
node tools/performance/check-baseline-direction.mjs \
|
||||
--base /tmp/base-journey-baselines.json \
|
||||
--head tools/performance/journey-baselines.json
|
||||
|
||||
# The production configuration is what users download; measuring
|
||||
# any other build would ratchet a number nobody ships.
|
||||
- name: Build web app (production)
|
||||
run: pnpm nx build web --skip-nx-cache
|
||||
env:
|
||||
CI: true
|
||||
NX_TASKS_RUNNER_DYNAMIC_OUTPUT: false
|
||||
|
||||
# Fails when renderer.initialBytes exceeds the value committed in
|
||||
# tools/performance/journey-baselines.json. Baselines only move
|
||||
# down, with the printed measurement as evidence; the contract is
|
||||
# docs/architecture/performance-journeys.md.
|
||||
- name: Check renderer.initialBytes against the baseline
|
||||
run: pnpm run perf:initial-bytes:check
|
||||
|
||||
- name: Upload journey summary
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: performance-journey-summary
|
||||
path: dist/performance/
|
||||
retention-days: 14
|
||||
|
||||
unit-and-typecheck:
|
||||
name: Unit Tests and Typechecks
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -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 CI 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).
|
||||
|
||||
To benchmark the "launch to usable" journey (fresh Electron process on a
|
||||
seeded profile, exact renderer counters plus wall-clock), run:
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 160 KiB After Width: | Height: | Size: 158 KiB |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 95 KiB |
@@ -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',
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { formatDate } from '@angular/common';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { normalizeDateLocale } from '@iptvnator/pipes';
|
||||
import { Language } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
APP_DATE_LOCALE_LOADERS,
|
||||
AppDateLocaleService,
|
||||
registerAppDateLocale,
|
||||
} from './app-date-locales';
|
||||
|
||||
const sampleDate = new Date(2024, 0, 15, 13, 5);
|
||||
|
||||
describe('registerAppDateLocale', () => {
|
||||
it('resolves for English without loading anything', async () => {
|
||||
const loader = jest.fn(() => import('@angular/common/locales/de'));
|
||||
|
||||
await registerAppDateLocale('en', { en: loader });
|
||||
await registerAppDateLocale('', { en: loader });
|
||||
await registerAppDateLocale(undefined, { en: loader });
|
||||
|
||||
expect(loader).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('loads the locale once and shares the in-flight import', async () => {
|
||||
const loader = jest.fn(() => import('@angular/common/locales/fr'));
|
||||
|
||||
await Promise.all([
|
||||
registerAppDateLocale('fr', { fr: loader }),
|
||||
registerAppDateLocale('fr', { fr: loader }),
|
||||
]);
|
||||
await registerAppDateLocale('fr', { fr: loader });
|
||||
|
||||
expect(loader).toHaveBeenCalledTimes(1);
|
||||
expect(formatDate(sampleDate, 'MMMM', 'fr')).toBe('janvier');
|
||||
});
|
||||
|
||||
it('maps app language aliases to Angular locale ids before loading', async () => {
|
||||
const loader = jest.fn(() => import('@angular/common/locales/be'));
|
||||
|
||||
await registerAppDateLocale('by', { be: loader });
|
||||
|
||||
expect(loader).toHaveBeenCalledTimes(1);
|
||||
expect(formatDate(sampleDate, 'MMMM', 'be')).toBe('студзеня');
|
||||
});
|
||||
|
||||
it('falls back to English formatting on a failed import and retries on the next call', async () => {
|
||||
const failing = jest.fn(() => Promise.reject(new Error('offline')));
|
||||
const working = jest.fn(() => import('@angular/common/locales/pl'));
|
||||
|
||||
await expect(
|
||||
registerAppDateLocale('pl', { pl: failing })
|
||||
).resolves.toBeUndefined();
|
||||
expect(formatDate(sampleDate, 'MMMM', 'pl')).toBe('January');
|
||||
|
||||
await registerAppDateLocale('pl', { pl: working });
|
||||
|
||||
expect(working).toHaveBeenCalledTimes(1);
|
||||
expect(formatDate(sampleDate, 'MMMM', 'pl')).toBe('stycznia');
|
||||
});
|
||||
|
||||
it('resolves for a language without bundled data instead of rejecting', async () => {
|
||||
await expect(registerAppDateLocale('xx', {})).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('bundles a loader for every supported language except English', () => {
|
||||
const expectedLocales = Object.values(Language)
|
||||
.filter((language) => language !== Language.ENGLISH)
|
||||
.map((language) => normalizeDateLocale(language))
|
||||
.sort();
|
||||
|
||||
expect(Object.keys(APP_DATE_LOCALE_LOADERS).sort()).toEqual(
|
||||
expectedLocales
|
||||
);
|
||||
});
|
||||
|
||||
it('registers working data for every supported language', async () => {
|
||||
for (const language of Object.values(Language)) {
|
||||
await registerAppDateLocale(language);
|
||||
const locale = normalizeDateLocale(language);
|
||||
expect(formatDate(sampleDate, 'MMMM', locale)).not.toBe('');
|
||||
}
|
||||
expect(formatDate(sampleDate, 'MMMM', 'ar-MA')).toBe('يناير');
|
||||
expect(formatDate(sampleDate, 'MMMM', 'zh-Hant')).toBe('1月');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AppDateLocaleService', () => {
|
||||
let translate: { use: jest.Mock };
|
||||
let service: AppDateLocaleService;
|
||||
|
||||
beforeEach(() => {
|
||||
translate = { use: jest.fn() };
|
||||
TestBed.configureTestingModule({
|
||||
providers: [{ provide: TranslateService, useValue: translate }],
|
||||
});
|
||||
service = TestBed.inject(AppDateLocaleService);
|
||||
});
|
||||
|
||||
it('delegates register to registerAppDateLocale', async () => {
|
||||
await expect(
|
||||
service.register(Language.GERMAN)
|
||||
).resolves.toBeUndefined();
|
||||
expect(formatDate(sampleDate, 'MMMM', 'de')).toBe('Januar');
|
||||
});
|
||||
|
||||
it('registers the locale data before switching the language', async () => {
|
||||
await service.use(Language.HUNGARIAN);
|
||||
|
||||
expect(translate.use).toHaveBeenCalledWith(Language.HUNGARIAN);
|
||||
expect(formatDate(sampleDate, 'MMMM', 'hu')).toBe('január');
|
||||
});
|
||||
|
||||
it('applies only the language requested last when switches overlap', async () => {
|
||||
await Promise.all([
|
||||
service.use(Language.ITALIAN),
|
||||
service.use(Language.TURKISH),
|
||||
]);
|
||||
|
||||
expect(translate.use).toHaveBeenCalledTimes(1);
|
||||
expect(translate.use).toHaveBeenCalledWith(Language.TURKISH);
|
||||
});
|
||||
});
|
||||
@@ -1,48 +1,123 @@
|
||||
import { registerLocaleData } from '@angular/common';
|
||||
import localeAr from '@angular/common/locales/ar';
|
||||
import localeArMa from '@angular/common/locales/ar-MA';
|
||||
import localeBe from '@angular/common/locales/be';
|
||||
import localeDe from '@angular/common/locales/de';
|
||||
import localeEl from '@angular/common/locales/el';
|
||||
import localeEs from '@angular/common/locales/es';
|
||||
import localeFr from '@angular/common/locales/fr';
|
||||
import localeHu from '@angular/common/locales/hu';
|
||||
import localeIt from '@angular/common/locales/it';
|
||||
import localeJa from '@angular/common/locales/ja';
|
||||
import localeKo from '@angular/common/locales/ko';
|
||||
import localeNl from '@angular/common/locales/nl';
|
||||
import localePl from '@angular/common/locales/pl';
|
||||
import localePt from '@angular/common/locales/pt';
|
||||
import localeRu from '@angular/common/locales/ru';
|
||||
import localeTr from '@angular/common/locales/tr';
|
||||
import localeZh from '@angular/common/locales/zh';
|
||||
import localeZhHant from '@angular/common/locales/zh-Hant';
|
||||
import localeEn from '@angular/common/locales/en';
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { normalizeDateLocale } from '@iptvnator/pipes';
|
||||
import { createDevLogger } from '@iptvnator/shared/interfaces';
|
||||
|
||||
let localesRegistered = false;
|
||||
type LocaleDataModule = { default: unknown };
|
||||
export type AppDateLocaleLoader = () => Promise<LocaleDataModule>;
|
||||
|
||||
export function registerAppDateLocales(): void {
|
||||
if (localesRegistered) {
|
||||
return;
|
||||
const debug = createDevLogger('AppDateLocales');
|
||||
|
||||
/**
|
||||
* Angular ships only English locale data; every other UI language needs its
|
||||
* data registered before `DatePipe` can format with it. Each entry is a
|
||||
* separate lazy chunk, keyed by the Angular locale id that
|
||||
* `normalizeDateLocale()` produces for an app language (`by` -> `be`,
|
||||
* `ary` -> `ar-MA`, `zhtw` -> `zh-Hant`). Importing all of them eagerly put
|
||||
* the data for 18 languages into the initial bundle of every user.
|
||||
*/
|
||||
export const APP_DATE_LOCALE_LOADERS: Readonly<
|
||||
Record<string, AppDateLocaleLoader>
|
||||
> = {
|
||||
ar: () => import('@angular/common/locales/ar'),
|
||||
'ar-MA': () => import('@angular/common/locales/ar-MA'),
|
||||
be: () => import('@angular/common/locales/be'),
|
||||
de: () => import('@angular/common/locales/de'),
|
||||
el: () => import('@angular/common/locales/el'),
|
||||
es: () => import('@angular/common/locales/es'),
|
||||
fr: () => import('@angular/common/locales/fr'),
|
||||
hu: () => import('@angular/common/locales/hu'),
|
||||
it: () => import('@angular/common/locales/it'),
|
||||
ja: () => import('@angular/common/locales/ja'),
|
||||
ko: () => import('@angular/common/locales/ko'),
|
||||
nl: () => import('@angular/common/locales/nl'),
|
||||
pl: () => import('@angular/common/locales/pl'),
|
||||
pt: () => import('@angular/common/locales/pt'),
|
||||
ru: () => import('@angular/common/locales/ru'),
|
||||
tr: () => import('@angular/common/locales/tr'),
|
||||
zh: () => import('@angular/common/locales/zh'),
|
||||
'zh-Hant': () => import('@angular/common/locales/zh-Hant'),
|
||||
};
|
||||
|
||||
const registeredLocales = new Set<string>(['en']);
|
||||
const pendingLocales = new Map<string, Promise<void>>();
|
||||
|
||||
/**
|
||||
* Registers the Angular locale data for an app language, loading it on first
|
||||
* use. Resolves once `DatePipe` can format with that locale, so call it before
|
||||
* `TranslateService.use()`: the language switch re-renders every date with the
|
||||
* new locale, and a locale whose data has not arrived throws. English resolves
|
||||
* immediately, as does a locale that is already registered or in flight.
|
||||
*
|
||||
* A failed load resolves rather than rejects, but never leaves the locale
|
||||
* without data: English formatting is registered under the requested id so
|
||||
* `DatePipe` keeps rendering instead of throwing in every template that passes
|
||||
* the locale. The locale is not marked registered, so the next call retries
|
||||
* the import and a success replaces the fallback.
|
||||
*/
|
||||
export function registerAppDateLocale(
|
||||
language: string | null | undefined,
|
||||
loaders: Readonly<
|
||||
Record<string, AppDateLocaleLoader>
|
||||
> = APP_DATE_LOCALE_LOADERS
|
||||
): Promise<void> {
|
||||
const locale = normalizeDateLocale(language);
|
||||
if (registeredLocales.has(locale)) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
const inFlight = pendingLocales.get(locale);
|
||||
if (inFlight) {
|
||||
return inFlight;
|
||||
}
|
||||
const loader = loaders[locale];
|
||||
if (!loader) {
|
||||
debug('No bundled Angular locale data for', locale);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
registerLocaleData(localeAr, 'ar');
|
||||
registerLocaleData(localeArMa, 'ar-MA');
|
||||
registerLocaleData(localeBe, 'be');
|
||||
registerLocaleData(localeDe, 'de');
|
||||
registerLocaleData(localeEl, 'el');
|
||||
registerLocaleData(localeEs, 'es');
|
||||
registerLocaleData(localeFr, 'fr');
|
||||
registerLocaleData(localeHu, 'hu');
|
||||
registerLocaleData(localeIt, 'it');
|
||||
registerLocaleData(localeJa, 'ja');
|
||||
registerLocaleData(localeKo, 'ko');
|
||||
registerLocaleData(localeNl, 'nl');
|
||||
registerLocaleData(localePl, 'pl');
|
||||
registerLocaleData(localePt, 'pt');
|
||||
registerLocaleData(localeRu, 'ru');
|
||||
registerLocaleData(localeTr, 'tr');
|
||||
registerLocaleData(localeZh, 'zh');
|
||||
registerLocaleData(localeZhHant, 'zh-Hant');
|
||||
|
||||
localesRegistered = true;
|
||||
const load = loader()
|
||||
.then((module) => {
|
||||
registerLocaleData(module.default, locale);
|
||||
registeredLocales.add(locale);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
debug(
|
||||
'Loading Angular locale data failed; using English formatting for',
|
||||
locale,
|
||||
error
|
||||
);
|
||||
registerLocaleData(localeEn, locale);
|
||||
})
|
||||
.finally(() => {
|
||||
pendingLocales.delete(locale);
|
||||
});
|
||||
pendingLocales.set(locale, load);
|
||||
return load;
|
||||
}
|
||||
|
||||
/** Gates UI language switches on the locale data they render with. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AppDateLocaleService {
|
||||
private readonly translate = inject(TranslateService);
|
||||
private latestRequest = 0;
|
||||
|
||||
register(language: string | null | undefined): Promise<void> {
|
||||
return registerAppDateLocale(language);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the locale data, then switches the UI language. Switches are
|
||||
* ordered by request, not by completion: a switch whose data arrives
|
||||
* after a newer request was made is dropped, so the language chosen last
|
||||
* is the one that ends up active.
|
||||
*/
|
||||
async use(language: string): Promise<void> {
|
||||
const request = ++this.latestRequest;
|
||||
await registerAppDateLocale(language);
|
||||
if (request === this.latestRequest) {
|
||||
this.translate.use(language);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { PlaylistActions } from '@iptvnator/m3u-state';
|
||||
import { AppComponent } from './app.component';
|
||||
import { AppDateLocaleService } from './app-date-locales';
|
||||
import { ElectronServiceStub } from './services/electron.service.stub';
|
||||
import { SettingsService } from './services/settings.service';
|
||||
|
||||
@@ -136,6 +137,11 @@ describe('AppComponent', () => {
|
||||
setDefaultLang: jest.fn(),
|
||||
use: jest.fn(),
|
||||
}),
|
||||
// The real service imports locale chunks; the language switch
|
||||
// is gated on it, so it must resolve deterministically here.
|
||||
MockProvider(AppDateLocaleService, {
|
||||
use: jest.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
{
|
||||
provide: WORKSPACE_SHELL_ACTIONS,
|
||||
useValue: {
|
||||
@@ -223,12 +229,12 @@ describe('AppComponent', () => {
|
||||
});
|
||||
settingsService.getValueFromLocalStorage.mockReturnValue(of(settings));
|
||||
jest.spyOn(settingsService, 'changeTheme');
|
||||
jest.spyOn(translateService, 'use');
|
||||
const dateLocales = TestBed.inject(AppDateLocaleService);
|
||||
|
||||
component.initSettings();
|
||||
await fixture.whenStable();
|
||||
|
||||
expect(translateService.use).toHaveBeenCalledWith(Language.SPANISH);
|
||||
expect(dateLocales.use).toHaveBeenCalledWith(Language.SPANISH);
|
||||
expect(settingsService.changeTheme).toHaveBeenCalledWith(
|
||||
Theme.DarkTheme
|
||||
);
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
Theme,
|
||||
createDevLogger,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { AppDateLocaleService } from './app-date-locales';
|
||||
import { SettingsService } from './services/settings.service';
|
||||
import { PlaybackKeepAwakeService } from './services/playback-keep-awake.service';
|
||||
import { PlaylistOpenRequestService } from './services/playlist-open-request.service';
|
||||
@@ -69,6 +70,7 @@ export class AppComponent implements OnInit {
|
||||
}
|
||||
private actions$ = inject(Actions);
|
||||
private dataService = inject(DataService);
|
||||
private readonly dateLocales = inject(AppDateLocaleService);
|
||||
private epgBridge = inject(EpgRuntimeBridgeService);
|
||||
private epgService = inject(EpgService);
|
||||
private snackBar = inject(MatSnackBar);
|
||||
@@ -149,7 +151,9 @@ export class AppComponent implements OnInit {
|
||||
// Only specific Electron settings (MPV/VLC paths) are sent when changed in settings component
|
||||
|
||||
const resolvedLang = settings.language ?? this.DEFAULT_LANG;
|
||||
this.translate.use(resolvedLang);
|
||||
// The switch re-renders every date with the new locale;
|
||||
// its data is a lazy chunk that must be registered first.
|
||||
void this.dateLocales.use(resolvedLang);
|
||||
// Mirror the active language to localStorage so the next
|
||||
// cold start can read it synchronously in app.config.ts's
|
||||
// getInitialLanguage() and avoid the English-then-localized
|
||||
|
||||
@@ -12,10 +12,10 @@ import {
|
||||
Language,
|
||||
Theme,
|
||||
} from '@iptvnator/shared/interfaces';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { SettingsSnackbarService } from './settings-snackbar.service';
|
||||
import { SettingsStore } from '../services/settings-store.service';
|
||||
import { SettingsService } from '../services/settings.service';
|
||||
import { AppDateLocaleService } from '../app-date-locales';
|
||||
import {
|
||||
applyEpgUrlsToFormArray,
|
||||
createEpgUrlControl,
|
||||
@@ -32,6 +32,7 @@ type SettingsFormPatch = Parameters<SettingsForm['patchValue']>[0];
|
||||
*/
|
||||
@Injectable()
|
||||
export class SettingsFormFacade {
|
||||
private readonly dateLocales = inject(AppDateLocaleService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly epgBridge = inject(EpgRuntimeBridgeService);
|
||||
private readonly formBuilder = inject(FormBuilder);
|
||||
@@ -39,7 +40,6 @@ export class SettingsFormFacade {
|
||||
private readonly settingsService = inject(SettingsService);
|
||||
private readonly settingsSnackbar = inject(SettingsSnackbarService);
|
||||
private readonly settingsStore = inject(SettingsStore);
|
||||
private readonly translate = inject(TranslateService);
|
||||
|
||||
readonly supportsEpg =
|
||||
this.epgBridge.supportsImport && this.epgBridge.supportsDataManagement;
|
||||
@@ -192,7 +192,10 @@ export class SettingsFormFacade {
|
||||
/** Applies the saved language/theme and resets the dirty state */
|
||||
applySavedSettings(): void {
|
||||
this.form.markAsPristine();
|
||||
this.translate.use(this.form.value.language ?? Language.ENGLISH);
|
||||
// The switch re-renders every date with the new locale; its data is
|
||||
// a lazy chunk that must be registered first, and a newer choice
|
||||
// must win over an older one whose data arrives later.
|
||||
void this.dateLocales.use(this.form.value.language ?? Language.ENGLISH);
|
||||
this.settingsService.changeTheme(
|
||||
this.form.value.theme ?? Theme.SystemTheme
|
||||
);
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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',
|
||||
};
|
||||
@@ -1,10 +1,8 @@
|
||||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { resolveRestoredRendererRoute } from '@iptvnator/shared/interfaces';
|
||||
import { registerAppDateLocales } from './app/app-date-locales';
|
||||
import { registerAppDateLocale } from './app/app-date-locales';
|
||||
import { AppComponent } from './app/app.component';
|
||||
import { appConfig } from './app/app.config';
|
||||
|
||||
registerAppDateLocales();
|
||||
import { appConfig, getInitialLanguage } from './app/app.config';
|
||||
|
||||
// A reloaded packaged renderer arrives on index.html with the route it was
|
||||
// on carried in the query string (the Electron main process recovers the
|
||||
@@ -18,7 +16,12 @@ if (restoredHref !== null) {
|
||||
window.history.replaceState(window.history.state, '', restoredHref);
|
||||
}
|
||||
|
||||
bootstrapApplication(AppComponent, appConfig)
|
||||
// Angular ships only English locale data; the preferred language's data is a
|
||||
// lazy chunk. It is awaited before bootstrapping because the first route
|
||||
// renders dates with that locale, and a locale without data throws. English
|
||||
// (the default) resolves immediately.
|
||||
registerAppDateLocale(getInitialLanguage())
|
||||
.then(() => bootstrapApplication(AppComponent, appConfig))
|
||||
.then(() => {
|
||||
// Splash is rendered eagerly by index.html so the user sees something
|
||||
// immediately instead of a blank Material-grey background. Once Angular
|
||||
|
||||
@@ -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;
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
- Date display should follow the user-selected app language from `TranslateService`.
|
||||
- When a template renders localized month or weekday names, pass the normalized app locale explicitly to `DatePipe`.
|
||||
- Angular locale data is registered in [apps/web/src/app/app-date-locales.ts](/apps/web/src/app/app-date-locales.ts).
|
||||
- Angular locale data is loaded lazily per language by `registerAppDateLocale()` in [apps/web/src/app/app-date-locales.ts](/apps/web/src/app/app-date-locales.ts): `main.ts` awaits the initial language's data before bootstrapping, and every `TranslateService.use()` call site registers the data first (through `AppDateLocaleService`), because the switch re-renders dates with the new locale and a locale without data throws. English needs no data. `AppDateLocaleService.use()` applies only the language requested last when switches overlap, and a locale whose chunk fails to load falls back to English formatting under that locale id until a later call loads it.
|
||||
- App language aliases are normalized in [libs/ui/pipes/src/lib/date-format.util.ts](/libs/ui/pipes/src/lib/date-format.util.ts):
|
||||
- `ary` -> `ar-MA`
|
||||
- `by` -> `be`
|
||||
|
||||
@@ -4,8 +4,8 @@ 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. The journey harness
|
||||
lives in `apps/electron-backend-e2e/src/journeys` and
|
||||
evidence. This document is the contract for that loop. The journey harness lives in
|
||||
`apps/electron-backend-e2e/src/journeys` and
|
||||
`apps/electron-backend-e2e/src/performance/journey-*.ts`; the ratchet scripts
|
||||
live in `tools/performance/`.
|
||||
|
||||
@@ -18,8 +18,9 @@ live in `tools/performance/`.
|
||||
| J3 `playback` | click on a channel | HTML5 `playing` event |
|
||||
| J4 `search` | six-character query typed into global search | results list settled |
|
||||
|
||||
J1 is instrumented by the launch benchmark below. J2 to J4 follow the plan in
|
||||
`.plans/` and are added one thread at a time; each thread names its journey
|
||||
J1 is instrumented today: `renderer.initialBytes` from the built output, and
|
||||
the runtime counters of the launch benchmark below. J2 to J4 follow the plan
|
||||
in `.plans/` and are added one thread at a time; each thread names its journey
|
||||
and counter in the PR description.
|
||||
|
||||
## Running the journeys
|
||||
@@ -161,6 +162,131 @@ numbers so `tools/performance/check-journey-ratchet.mjs` can compare them with
|
||||
`tools/performance/journey-baselines.json`. A J1 baseline is added once the
|
||||
numbers are stable on the CI runner; until then the summary is evidence only.
|
||||
|
||||
## `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, so the value is the same for every language: a non-English launch
|
||||
additionally fetches that language's Angular locale chunk (about 2 KB), which
|
||||
belongs to the per-profile J1 benchmark rather than to this counter. 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`.
|
||||
|
||||
## Ratchet
|
||||
|
||||
`tools/performance/journey-baselines.json` holds one entry per journey and
|
||||
counter:
|
||||
|
||||
```json
|
||||
{
|
||||
"journeys": {
|
||||
"launch": {
|
||||
"renderer.initialBytes": {
|
||||
"value": 2739510,
|
||||
"unit": "bytes",
|
||||
"updatedAt": "2026-09-26",
|
||||
"evidencePr": 1693,
|
||||
"measuredWith": "pnpm nx build web && pnpm run perf:initial-bytes"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`tools/performance/check-journey-ratchet.mjs` compares a journey summary with
|
||||
that file:
|
||||
|
||||
- a counter above its `value` fails; counters are exact, there is no slack;
|
||||
- a wall-clock entry carries `toleranceRatio` and fails above
|
||||
`value × toleranceRatio`;
|
||||
- a baseline with no measurement in the summary fails, so dropping a
|
||||
measurement cannot disable the ratchet; a counter is read only from
|
||||
`journeys.<journey>.counters` and a wall-clock entry (one with
|
||||
`toleranceRatio`) only from `journeys.<journey>.wallClock`, so a value in
|
||||
the wrong section also counts as missing;
|
||||
- a measurement below its baseline passes and prints a "tighten" hint;
|
||||
- a measured counter without a baseline is noted, not failed;
|
||||
- checking nothing fails: an empty baselines file, or `--only` naming an
|
||||
entry that does not exist, cannot exit 0.
|
||||
|
||||
`--only <journey>/<counter>` (repeatable) restricts the check to the named
|
||||
baselines. A script that measures one counter writes its own summary file
|
||||
and checks only its counter, so it neither overwrites another measurement's
|
||||
summary nor fails the other baselines as unmeasured.
|
||||
|
||||
```bash
|
||||
pnpm run perf:initial-bytes:check # measure dist/apps/web into dist/performance/initial-bytes.summary.json, check only that counter
|
||||
pnpm run perf:ratchet:check # check every baseline against dist/performance/journey-summary.json
|
||||
```
|
||||
|
||||
CI runs `perf:initial-bytes:check` in the `Initial bytes ratchet` job of
|
||||
`.github/workflows/ci.yml` after a production build of `apps/web`, and uploads
|
||||
`dist/performance/` as the `performance-journey-summary` artifact. Like the
|
||||
rest of that workflow it runs for pull requests that target `master` and for
|
||||
pushes to `master`; a stacked PR that targets another branch gets no run until
|
||||
it is retargeted, so dispatch one with `gh workflow run ci.yml --ref <branch>`
|
||||
when you need the number. A PR that grows the counter fails that job.
|
||||
|
||||
That runner is the canonical measurer: take baseline values from its output,
|
||||
not from a local build. A local macOS build of the code before #1695 is 2
|
||||
bytes smaller in `main.js` (the eager locale imports); since #1695 the two
|
||||
have been byte-identical. (An apparent 556-byte platform difference during
|
||||
the first measurements was otherwise `package.json` text embedded in
|
||||
`main.js`, which moved with every script edit; #1692 fixed that by importing
|
||||
only the version.)
|
||||
|
||||
The job also refuses a weakened baselines file:
|
||||
`tools/performance/check-baseline-direction.mjs` compares
|
||||
`journey-baselines.json` with the revision the change is measured against
|
||||
(the target branch of a pull request, the previous head of a `master` push,
|
||||
`master` for a manual dispatch) and fails when any
|
||||
entry's enforced limit (`value × toleranceRatio`) went up, a tolerance widened
|
||||
or an entry disappeared, so a PR cannot grow the payload and raise the
|
||||
baseline to match. Lowered limits and new entries pass.
|
||||
|
||||
Baselines only move down. Lower `value` in the same PR as the change that
|
||||
earned it, set `updatedAt` and `evidencePr`, and paste the measurement output
|
||||
into the PR. Never raise a value to make a PR pass: if growth is a deliberate
|
||||
trade-off, say so in the PR and let the maintainer decide.
|
||||
|
||||
## 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.
|
||||
|
||||
## Adding a journey
|
||||
|
||||
1. Add `apps/electron-backend-e2e/src/journeys/<journey>.journey.ts`. Seed the
|
||||
|
||||
@@ -120,7 +120,6 @@ and JSON summary output. CI uploads the merged Tier A report to Codecov with the
|
||||
| Web app browser flows | `pnpm nx run web-e2e:e2e -- --project=chromium` |
|
||||
| Electron flows | `pnpm nx run electron-backend-e2e:e2e` |
|
||||
| VOD multi-source | `pnpm nx run electron-backend-e2e:e2e-ci--src/vod-multi-source.e2e.ts` |
|
||||
| Performance journeys | `pnpm run perf:journeys` (see [performance journeys](performance-journeys.md)) |
|
||||
|
||||
Use atomized E2E targets when available, for example
|
||||
`pnpm nx run web-e2e:e2e-ci--src/xtream.e2e.ts`.
|
||||
@@ -154,6 +153,28 @@ 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 # breakdown only
|
||||
pnpm run perf:initial-bytes:check # measure, then compare with the committed baseline
|
||||
pnpm nx test performance-tools
|
||||
pnpm run perf:journeys # J1 launch benchmark, writes dist/performance/journeys/<timestamp>/summary.json
|
||||
```
|
||||
|
||||
`perf:initial-bytes` reads the built `dist/apps/web/index.html` and sums the
|
||||
bytes on the initial path (the J1 counter `renderer.initialBytes`).
|
||||
`perf:initial-bytes:check` then fails if the value exceeds
|
||||
`tools/performance/journey-baselines.json`; baselines only move down. CI runs
|
||||
the same check in the `Initial bytes ratchet` job of `ci.yml` for PRs that
|
||||
target `master` and for `master` pushes (dispatch it with
|
||||
`gh workflow run ci.yml --ref <branch>` for a stacked branch). `perf:journeys` builds the `electron-performance` configuration and runs the
|
||||
J1 launch benchmark against the Xtream mock; its probe specs run with
|
||||
`pnpm nx run electron-backend-e2e:test-performance-harness`. The contract, what
|
||||
counts and how to add a counter or a journey are in the
|
||||
[performance journeys](performance-journeys.md) document.
|
||||
|
||||
## Logging
|
||||
|
||||
Runtime playback and EPG debug logs should use the existing logger or trace
|
||||
|
||||
@@ -14,7 +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, benchmark probes and the ratchet; `apps/electron-backend-e2e/src/journeys`, `apps/electron-backend-e2e/src/performance` | [Performance journeys](../architecture/performance-journeys.md) | None |
|
||||
| Performance journeys, counters, benchmark probes and the CI ratchet; `apps/electron-backend-e2e/src/journeys`, `apps/electron-backend-e2e/src/performance`, `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) |
|
||||
|
||||
|
||||
@@ -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 ?? ''
|
||||
)
|
||||
);
|
||||
|
||||
@@ -71,7 +71,11 @@
|
||||
"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:initial-bytes:check": "node tools/performance/measure-initial-bytes.mjs --summary dist/performance/initial-bytes.summary.json && node tools/performance/check-journey-ratchet.mjs --summary dist/performance/initial-bytes.summary.json --only launch/renderer.initialBytes",
|
||||
"perf:journeys": "nx run electron-backend-e2e:journeys",
|
||||
"perf:ratchet:check": "node tools/performance/check-journey-ratchet.mjs --summary dist/performance/journey-summary.json",
|
||||
"perf:tools:test": "node --test tools/performance/measure-initial-bytes.test.mjs tools/performance/check-journey-ratchet.test.mjs tools/performance/check-baseline-direction.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,175 @@
|
||||
/**
|
||||
* Refuses a change that weakens tools/performance/journey-baselines.json.
|
||||
*
|
||||
* The ratchet job compares a measurement with the baselines file of the same
|
||||
* commit, so on its own it cannot tell a genuine payload reduction from a PR
|
||||
* that grows the payload and raises the baseline by the same amount. This
|
||||
* check closes that gap: given the baselines file of the target branch and
|
||||
* the one of the PR, any entry whose enforced limit (`value × toleranceRatio`)
|
||||
* went up, whose tolerance widened, or that disappeared, is a failure. New
|
||||
* entries and lowered limits pass.
|
||||
*
|
||||
* Usage:
|
||||
* node tools/performance/check-baseline-direction.mjs \
|
||||
* --base <target-branch-journey-baselines.json> \
|
||||
* --head tools/performance/journey-baselines.json
|
||||
*
|
||||
* A missing --base file means the target branch has no baselines yet, so
|
||||
* there is nothing that could have been weakened.
|
||||
*/
|
||||
import { existsSync } from 'node:fs';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { validateBaselines } from './check-journey-ratchet.mjs';
|
||||
|
||||
export const DEFAULT_HEAD_PATH = 'tools/performance/journey-baselines.json';
|
||||
|
||||
function entries(baselines) {
|
||||
const flat = new Map();
|
||||
for (const [journey, counters] of Object.entries(baselines.journeys)) {
|
||||
for (const [name, entry] of Object.entries(counters)) {
|
||||
flat.set(`${journey}/${name}`, entry);
|
||||
}
|
||||
}
|
||||
return flat;
|
||||
}
|
||||
|
||||
function formatNumber(value) {
|
||||
return value.toLocaleString('en-US', { maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
/**
|
||||
* What the ratchet actually enforces: `value × toleranceRatio` for a
|
||||
* wall-clock entry, the bare value for a counter. Comparing values alone would
|
||||
* let a PR lower a value while widening the tolerance.
|
||||
*/
|
||||
function effectiveLimit(entry) {
|
||||
return entry.value * (entry.toleranceRatio ?? 1);
|
||||
}
|
||||
|
||||
/** Pure comparison; `failures` non-empty means the change weakens the ratchet. */
|
||||
export function compareBaselineDirection({ base, head }) {
|
||||
validateBaselines(base);
|
||||
validateBaselines(head);
|
||||
const result = { failures: [], lowered: [], unchanged: [], added: [] };
|
||||
const headEntries = entries(head);
|
||||
|
||||
for (const [label, baseEntry] of entries(base)) {
|
||||
const headEntry = headEntries.get(label);
|
||||
const unit = baseEntry.unit ? ` ${baseEntry.unit}` : '';
|
||||
if (!headEntry) {
|
||||
result.failures.push(
|
||||
`${label}: baseline ${formatNumber(baseEntry.value)}${unit} was removed. Baselines are retired only by a maintainer decision recorded in the PR, not by deleting the entry.`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const baseTolerance = baseEntry.toleranceRatio ?? 1;
|
||||
const headTolerance = headEntry.toleranceRatio ?? 1;
|
||||
const baseLimit = effectiveLimit(baseEntry);
|
||||
const headLimit = effectiveLimit(headEntry);
|
||||
if (headTolerance > baseTolerance) {
|
||||
result.failures.push(
|
||||
`${label}: toleranceRatio widened from ${baseTolerance} to ${headTolerance}. Tolerances are a maintainer decision; a PR may only narrow them.`
|
||||
);
|
||||
} else if (headLimit > baseLimit) {
|
||||
result.failures.push(
|
||||
`${label}: baseline raised from ${formatNumber(baseLimit)} to ${formatNumber(headLimit)}${unit}. Baselines only move down; bring the measurement back under ${formatNumber(baseLimit)} or make the case for the increase in the PR.`
|
||||
);
|
||||
} else if (headLimit < baseLimit) {
|
||||
result.lowered.push(
|
||||
`${label}: ${formatNumber(baseLimit)} -> ${formatNumber(headLimit)}${unit}.`
|
||||
);
|
||||
} else {
|
||||
result.unchanged.push(label);
|
||||
}
|
||||
}
|
||||
for (const label of headEntries.keys()) {
|
||||
if (!entries(base).has(label)) result.added.push(label);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function formatDirectionResult(result) {
|
||||
const lines = [];
|
||||
for (const line of result.lowered) lines.push(`lowered ${line}`);
|
||||
for (const label of result.added) lines.push(`added ${label}`);
|
||||
for (const line of result.failures) lines.push(`FAIL ${line}`);
|
||||
lines.push(
|
||||
result.failures.length > 0
|
||||
? `Baseline direction check failed: ${result.failures.length} entries raised or removed.`
|
||||
: `Baseline direction OK: ${result.unchanged.length} unchanged, ${result.lowered.length} lowered, ${result.added.length} added.`
|
||||
);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function parseArgs(argv) {
|
||||
const options = { base: null, head: DEFAULT_HEAD_PATH };
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const argument = argv[index];
|
||||
if (argument === '--') continue;
|
||||
if (argument === '--base') {
|
||||
options.base = argv[++index];
|
||||
} else if (argument.startsWith('--base=')) {
|
||||
options.base = argument.slice('--base='.length);
|
||||
} else if (argument === '--head') {
|
||||
options.head = argv[++index];
|
||||
} else if (argument.startsWith('--head=')) {
|
||||
options.head = argument.slice('--head='.length);
|
||||
} else {
|
||||
throw new Error(`Unknown argument: ${argument}`);
|
||||
}
|
||||
if (options.base === undefined || options.head === undefined) {
|
||||
throw new Error(`Missing value for ${argument}`);
|
||||
}
|
||||
}
|
||||
if (!options.base) {
|
||||
throw new Error(
|
||||
'--base <target-branch-journey-baselines.json> is required.'
|
||||
);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
async function readJson(filePath, description) {
|
||||
try {
|
||||
return JSON.parse(await readFile(filePath, 'utf8'));
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Cannot read ${description} at ${filePath}: ${error.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 basePath = path.resolve(options.base);
|
||||
const base = existsSync(basePath)
|
||||
? await readJson(basePath, 'target-branch baselines')
|
||||
: { version: 1, journeys: {} };
|
||||
if (!existsSync(basePath)) {
|
||||
console.log(
|
||||
`No baselines file at ${options.base} on the target branch; nothing to weaken.`
|
||||
);
|
||||
}
|
||||
const head = await readJson(path.resolve(options.head), 'baselines');
|
||||
const result = compareBaselineDirection({ base, head });
|
||||
const output = formatDirectionResult(result);
|
||||
if (result.failures.length > 0) {
|
||||
console.error(output);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log(output);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`check-baseline-direction: ${error.message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtemp, 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_HEAD_PATH,
|
||||
compareBaselineDirection,
|
||||
formatDirectionResult,
|
||||
parseArgs,
|
||||
} from './check-baseline-direction.mjs';
|
||||
|
||||
const scriptPath = fileURLToPath(
|
||||
new URL('./check-baseline-direction.mjs', import.meta.url)
|
||||
);
|
||||
|
||||
const file = (initialBytes, extra = {}) => ({
|
||||
version: 1,
|
||||
journeys: {
|
||||
launch: {
|
||||
'renderer.initialBytes': { value: initialBytes, unit: 'bytes' },
|
||||
...extra,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let workDir;
|
||||
before(async () => {
|
||||
workDir = await mkdtemp(
|
||||
path.join(os.tmpdir(), 'check-baseline-direction-')
|
||||
);
|
||||
});
|
||||
after(async () => {
|
||||
await rm(workDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('an unchanged or lowered baseline passes', () => {
|
||||
const same = compareBaselineDirection({ base: file(100), head: file(100) });
|
||||
assert.deepEqual(same.failures, []);
|
||||
assert.deepEqual(same.unchanged, ['launch/renderer.initialBytes']);
|
||||
|
||||
const lowered = compareBaselineDirection({
|
||||
base: file(100),
|
||||
head: file(90),
|
||||
});
|
||||
assert.deepEqual(lowered.failures, []);
|
||||
assert.match(lowered.lowered[0], /100 -> 90 bytes/);
|
||||
});
|
||||
|
||||
test('a raised baseline fails with both values', () => {
|
||||
const result = compareBaselineDirection({
|
||||
base: file(100),
|
||||
head: file(101),
|
||||
});
|
||||
assert.equal(result.failures.length, 1);
|
||||
assert.match(
|
||||
result.failures[0],
|
||||
/raised from 100 to 101 bytes\. Baselines only move down/
|
||||
);
|
||||
});
|
||||
|
||||
test('a removed baseline fails; a new one is reported and passes', () => {
|
||||
const removed = compareBaselineDirection({
|
||||
base: file(100, { cdTicks: { value: 5 } }),
|
||||
head: file(100),
|
||||
});
|
||||
assert.equal(removed.failures.length, 1);
|
||||
assert.match(
|
||||
removed.failures[0],
|
||||
/launch\/cdTicks: baseline 5 was removed/
|
||||
);
|
||||
|
||||
const added = compareBaselineDirection({
|
||||
base: file(100),
|
||||
head: file(100, { cdTicks: { value: 5 } }),
|
||||
});
|
||||
assert.deepEqual(added.failures, []);
|
||||
assert.deepEqual(added.added, ['launch/cdTicks']);
|
||||
});
|
||||
|
||||
test('a widened or newly added tolerance fails even when the value went down', () => {
|
||||
const widened = compareBaselineDirection({
|
||||
base: file(100, {
|
||||
firstCardMs: { value: 100, unit: 'ms', toleranceRatio: 1.1 },
|
||||
}),
|
||||
head: file(100, {
|
||||
firstCardMs: { value: 99, unit: 'ms', toleranceRatio: 2 },
|
||||
}),
|
||||
});
|
||||
assert.equal(widened.failures.length, 1);
|
||||
assert.match(
|
||||
widened.failures[0],
|
||||
/firstCardMs: toleranceRatio widened from 1\.1 to 2/
|
||||
);
|
||||
|
||||
const added = compareBaselineDirection({
|
||||
base: file(100),
|
||||
head: file(100, undefined) && {
|
||||
version: 1,
|
||||
journeys: {
|
||||
launch: {
|
||||
'renderer.initialBytes': {
|
||||
value: 100,
|
||||
unit: 'bytes',
|
||||
toleranceRatio: 1.25,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(added.failures.length, 1);
|
||||
assert.match(added.failures[0], /toleranceRatio widened from 1 to 1\.25/);
|
||||
});
|
||||
|
||||
test('the enforced limit is what is compared for wall-clock entries', () => {
|
||||
const narrowed = compareBaselineDirection({
|
||||
base: file(100, {
|
||||
firstCardMs: { value: 100, unit: 'ms', toleranceRatio: 1.25 },
|
||||
}),
|
||||
head: file(100, {
|
||||
firstCardMs: { value: 110, unit: 'ms', toleranceRatio: 1 },
|
||||
}),
|
||||
});
|
||||
assert.deepEqual(narrowed.failures, []);
|
||||
assert.match(narrowed.lowered[0], /firstCardMs: 125 -> 110 ms/);
|
||||
|
||||
const raisedLimit = compareBaselineDirection({
|
||||
base: file(100, {
|
||||
firstCardMs: { value: 100, unit: 'ms', toleranceRatio: 1.25 },
|
||||
}),
|
||||
head: file(100, {
|
||||
firstCardMs: { value: 130, unit: 'ms', toleranceRatio: 1.25 },
|
||||
}),
|
||||
});
|
||||
assert.equal(raisedLimit.failures.length, 1);
|
||||
assert.match(
|
||||
raisedLimit.failures[0],
|
||||
/firstCardMs: baseline raised from 125 to 162\.5 ms/
|
||||
);
|
||||
});
|
||||
|
||||
test('an empty target-branch file cannot be weakened', () => {
|
||||
const result = compareBaselineDirection({
|
||||
base: { journeys: {} },
|
||||
head: file(100),
|
||||
});
|
||||
assert.deepEqual(result.failures, []);
|
||||
assert.deepEqual(result.added, ['launch/renderer.initialBytes']);
|
||||
});
|
||||
|
||||
test('formats the outcome', () => {
|
||||
assert.match(
|
||||
formatDirectionResult(
|
||||
compareBaselineDirection({ base: file(100), head: file(90) })
|
||||
),
|
||||
/^lowered {2}launch\/renderer\.initialBytes: 100 -> 90 bytes\.\nBaseline direction OK: 0 unchanged, 1 lowered, 0 added\.$/
|
||||
);
|
||||
assert.match(
|
||||
formatDirectionResult(
|
||||
compareBaselineDirection({ base: file(100), head: file(200) })
|
||||
),
|
||||
/^FAIL {5}launch.*\nBaseline direction check failed: 1 entries raised or removed\.$/
|
||||
);
|
||||
});
|
||||
|
||||
test('parses arguments and requires --base', () => {
|
||||
assert.deepEqual(parseArgs(['--base', 'b.json']), {
|
||||
base: 'b.json',
|
||||
head: DEFAULT_HEAD_PATH,
|
||||
});
|
||||
assert.deepEqual(parseArgs(['--', '--base=b.json', '--head=h.json']), {
|
||||
base: 'b.json',
|
||||
head: 'h.json',
|
||||
});
|
||||
assert.throws(
|
||||
() => parseArgs([]),
|
||||
/--base <target-branch-journey-baselines\.json> is required/
|
||||
);
|
||||
assert.throws(() => parseArgs(['--base']), /Missing value for --base/);
|
||||
assert.throws(
|
||||
() => parseArgs(['--base', 'b', '--force']),
|
||||
/Unknown argument: --force/
|
||||
);
|
||||
});
|
||||
|
||||
async function runCli(base, head) {
|
||||
const basePath =
|
||||
base === null
|
||||
? path.join(workDir, 'absent.json')
|
||||
: path.join(workDir, `base-${Math.random()}.json`);
|
||||
const headPath = path.join(workDir, `head-${Math.random()}.json`);
|
||||
if (base !== null) await writeFile(basePath, JSON.stringify(base));
|
||||
await writeFile(headPath, JSON.stringify(head));
|
||||
return spawnSync(
|
||||
process.execPath,
|
||||
[scriptPath, '--base', basePath, '--head', headPath],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
test('CLI exits 0 for a lowered baseline, 1 for a raised one, 0 when the target branch has no file', async () => {
|
||||
const lowered = await runCli(file(100), file(90));
|
||||
assert.equal(lowered.status, 0, lowered.stderr);
|
||||
assert.match(lowered.stdout, /Baseline direction OK/);
|
||||
|
||||
const raised = await runCli(file(100), file(101));
|
||||
assert.equal(raised.status, 1);
|
||||
assert.match(
|
||||
raised.stderr,
|
||||
/FAIL {5}launch\/renderer\.initialBytes: baseline raised/
|
||||
);
|
||||
|
||||
const noBase = await runCli(null, file(100));
|
||||
assert.equal(noBase.status, 0, noBase.stderr);
|
||||
assert.match(noBase.stdout, /nothing to weaken/);
|
||||
});
|
||||
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* Compares a journey summary (written by the measurement scripts) against the
|
||||
* committed baselines in tools/performance/journey-baselines.json.
|
||||
*
|
||||
* Rules, from docs/architecture/performance-journeys.md:
|
||||
* - A counter above its baseline fails. Counters are exact; there is no slack.
|
||||
* - An entry with `toleranceRatio` (wall-clock) fails above
|
||||
* `value * toleranceRatio`.
|
||||
* - A baseline without a measurement fails, so removing a measurement can
|
||||
* never silently disable the ratchet.
|
||||
* - A measurement below its baseline prints a "can tighten" hint. Baselines
|
||||
* are lowered by hand, with the measured output as evidence, never raised.
|
||||
* - Checking nothing is a failure: an empty baselines file, or `--only`
|
||||
* naming an entry that does not exist, must not exit 0.
|
||||
*
|
||||
* `--only <journey>/<counter>` (repeatable) restricts the check to the named
|
||||
* baselines, so a script that measures one counter can check that counter
|
||||
* without every other baseline failing as unmeasured.
|
||||
*
|
||||
* Usage:
|
||||
* node tools/performance/check-journey-ratchet.mjs --summary <summary.json>
|
||||
* [--baselines tools/performance/journey-baselines.json]
|
||||
* [--only launch/renderer.initialBytes ...]
|
||||
*/
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export const DEFAULT_BASELINES_PATH =
|
||||
'tools/performance/journey-baselines.json';
|
||||
|
||||
function formatNumber(value) {
|
||||
return Number.isInteger(value)
|
||||
? value.toLocaleString('en-US')
|
||||
: value.toLocaleString('en-US', { maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function isPlainObject(value) {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function validateBaselines(baselines) {
|
||||
if (!isPlainObject(baselines) || !isPlainObject(baselines.journeys)) {
|
||||
throw new Error(
|
||||
'Baselines file must be an object with a "journeys" map.'
|
||||
);
|
||||
}
|
||||
for (const [journey, entries] of Object.entries(baselines.journeys)) {
|
||||
if (!isPlainObject(entries)) {
|
||||
throw new Error(
|
||||
`Baseline journey "${journey}" must map counter names to entries.`
|
||||
);
|
||||
}
|
||||
for (const [name, entry] of Object.entries(entries)) {
|
||||
const label = `${journey}/${name}`;
|
||||
if (!isPlainObject(entry) || !Number.isFinite(entry.value)) {
|
||||
throw new Error(
|
||||
`Baseline ${label} needs a finite numeric "value".`
|
||||
);
|
||||
}
|
||||
if (
|
||||
entry.toleranceRatio !== undefined &&
|
||||
!(
|
||||
Number.isFinite(entry.toleranceRatio) &&
|
||||
entry.toleranceRatio >= 1
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`Baseline ${label} has an invalid "toleranceRatio" (must be >= 1).`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return baselines;
|
||||
}
|
||||
|
||||
/**
|
||||
* A baseline entry is either a counter (exact) or a wall-clock entry (carries
|
||||
* `toleranceRatio`), and each is read only from its own summary section. A
|
||||
* value that moved to the other section is treated as missing, so a summary
|
||||
* schema regression fails the ratchet instead of slipping through it.
|
||||
*/
|
||||
function summarySection(entry) {
|
||||
return entry.toleranceRatio !== undefined ? 'wallClock' : 'counters';
|
||||
}
|
||||
|
||||
function measuredValue(summaryJourney, name, entry) {
|
||||
if (!isPlainObject(summaryJourney)) return undefined;
|
||||
return summaryJourney[summarySection(entry)]?.[name];
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure comparison. Returns every outcome so the CLI and tests can render it;
|
||||
* `failures` non-empty means the ratchet is broken.
|
||||
*/
|
||||
export function compareToBaselines({ baselines, summary, only = [] }) {
|
||||
validateBaselines(baselines);
|
||||
const summaryJourneys = isPlainObject(summary?.journeys)
|
||||
? summary.journeys
|
||||
: {};
|
||||
const result = {
|
||||
failures: [],
|
||||
tightenable: [],
|
||||
passed: [],
|
||||
unbaselined: [],
|
||||
};
|
||||
const selected = new Set(only);
|
||||
let evaluated = 0;
|
||||
|
||||
for (const target of only) {
|
||||
const [journey, ...rest] = target.split('/');
|
||||
if (baselines.journeys[journey]?.[rest.join('/')] === undefined) {
|
||||
result.failures.push(
|
||||
`${target}: --only names a baseline that does not exist in the baselines file.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [journey, entries] of Object.entries(baselines.journeys)) {
|
||||
for (const [name, entry] of Object.entries(entries)) {
|
||||
const label = `${journey}/${name}`;
|
||||
if (selected.size > 0 && !selected.has(label)) continue;
|
||||
evaluated += 1;
|
||||
const unit = entry.unit ? ` ${entry.unit}` : '';
|
||||
const measured = measuredValue(
|
||||
summaryJourneys[journey],
|
||||
name,
|
||||
entry
|
||||
);
|
||||
|
||||
if (measured === undefined) {
|
||||
result.failures.push(
|
||||
`${label}: baseline ${formatNumber(entry.value)}${unit} has no measurement under journeys.${journey}.${summarySection(entry)} in the summary. The ratchet cannot be bypassed by dropping a measurement; restore it.`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (typeof measured !== 'number' || !Number.isFinite(measured)) {
|
||||
result.failures.push(
|
||||
`${label}: measured value ${JSON.stringify(measured)} is not a finite number.`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const limit =
|
||||
entry.toleranceRatio !== undefined
|
||||
? entry.value * entry.toleranceRatio
|
||||
: entry.value;
|
||||
const limitText =
|
||||
entry.toleranceRatio !== undefined
|
||||
? `${formatNumber(limit)} (baseline ${formatNumber(entry.value)} × ${entry.toleranceRatio})`
|
||||
: `baseline ${formatNumber(entry.value)}`;
|
||||
|
||||
if (measured > limit) {
|
||||
result.failures.push(
|
||||
`${label}: ${formatNumber(measured)}${unit} exceeds ${limitText} by ${formatNumber(measured - limit)}${unit}. Bring the value back down; baselines only move down. If the growth is a deliberate trade-off, say so in the PR and let the maintainer decide.`
|
||||
);
|
||||
} else if (measured < entry.value) {
|
||||
result.tightenable.push(
|
||||
`${label}: ${formatNumber(measured)}${unit} is below baseline ${formatNumber(entry.value)} by ${formatNumber(entry.value - measured)}${unit}. Lower the baseline in ${DEFAULT_BASELINES_PATH} with this run as evidence.`
|
||||
);
|
||||
} else {
|
||||
result.passed.push(
|
||||
`${label}: ${formatNumber(measured)}${unit} within ${limitText}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [journey, summaryJourney] of Object.entries(summaryJourneys)) {
|
||||
const measuredNames = [
|
||||
...Object.keys(summaryJourney?.counters ?? {}),
|
||||
...Object.keys(summaryJourney?.wallClock ?? {}),
|
||||
];
|
||||
for (const name of measuredNames) {
|
||||
if (baselines.journeys[journey]?.[name] === undefined) {
|
||||
result.unbaselined.push(
|
||||
`${journey}/${name}: measured but has no baseline yet. Add one to ${DEFAULT_BASELINES_PATH} once the counter is validated.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (evaluated === 0) {
|
||||
result.failures.push(
|
||||
`No baselines were checked. ${DEFAULT_BASELINES_PATH} must keep at least one entry; a ratchet that guards nothing must not pass.`
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function formatResult(result) {
|
||||
const lines = [];
|
||||
for (const line of result.passed) lines.push(`ok ${line}`);
|
||||
for (const line of result.tightenable) lines.push(`tighten ${line}`);
|
||||
for (const line of result.unbaselined) lines.push(`note ${line}`);
|
||||
for (const line of result.failures) lines.push(`FAIL ${line}`);
|
||||
const checked =
|
||||
result.passed.length +
|
||||
result.tightenable.length +
|
||||
result.failures.length;
|
||||
lines.push(
|
||||
result.failures.length > 0
|
||||
? `Journey ratchet failed: ${result.failures.length} of ${checked} baselines exceeded.`
|
||||
: `Journey ratchet OK: ${checked} baselines checked, ${result.tightenable.length} can be tightened.`
|
||||
);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function parseArgs(argv) {
|
||||
const options = {
|
||||
summary: null,
|
||||
baselines: DEFAULT_BASELINES_PATH,
|
||||
only: [],
|
||||
};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const argument = argv[index];
|
||||
if (argument === '--') continue;
|
||||
if (argument === '--summary') {
|
||||
options.summary = argv[++index];
|
||||
} else if (argument.startsWith('--summary=')) {
|
||||
options.summary = argument.slice('--summary='.length);
|
||||
} else if (argument === '--baselines') {
|
||||
options.baselines = argv[++index];
|
||||
} else if (argument.startsWith('--baselines=')) {
|
||||
options.baselines = argument.slice('--baselines='.length);
|
||||
} else if (argument === '--only') {
|
||||
options.only.push(argv[++index]);
|
||||
} else if (argument.startsWith('--only=')) {
|
||||
options.only.push(argument.slice('--only='.length));
|
||||
} else {
|
||||
throw new Error(`Unknown argument: ${argument}`);
|
||||
}
|
||||
if (
|
||||
options.summary === undefined ||
|
||||
options.baselines === undefined ||
|
||||
options.only.includes(undefined)
|
||||
) {
|
||||
throw new Error(`Missing value for ${argument}`);
|
||||
}
|
||||
}
|
||||
for (const target of options.only) {
|
||||
if (!/^[^/]+\/.+$/.test(target)) {
|
||||
throw new Error(
|
||||
`--only expects <journey>/<counter>, received "${target}".`
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!options.summary) {
|
||||
throw new Error('--summary <journey-summary.json> is required.');
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
async function readJson(filePath, description) {
|
||||
try {
|
||||
return JSON.parse(await readFile(filePath, 'utf8'));
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Cannot read ${description} at ${filePath}: ${error.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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 baselines = await readJson(
|
||||
path.resolve(options.baselines),
|
||||
'baselines'
|
||||
);
|
||||
const summary = await readJson(
|
||||
path.resolve(options.summary),
|
||||
'journey summary'
|
||||
);
|
||||
const result = compareToBaselines({
|
||||
baselines,
|
||||
summary,
|
||||
only: options.only,
|
||||
});
|
||||
const output = formatResult(result);
|
||||
if (result.failures.length > 0) {
|
||||
console.error(output);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log(output);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`check-journey-ratchet: ${error.message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtemp, 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_BASELINES_PATH,
|
||||
compareToBaselines,
|
||||
formatResult,
|
||||
parseArgs,
|
||||
validateBaselines,
|
||||
} from './check-journey-ratchet.mjs';
|
||||
|
||||
const scriptPath = fileURLToPath(
|
||||
new URL('./check-journey-ratchet.mjs', import.meta.url)
|
||||
);
|
||||
const committedBaselinesPath = fileURLToPath(
|
||||
new URL('./journey-baselines.json', import.meta.url)
|
||||
);
|
||||
|
||||
const baselines = {
|
||||
version: 1,
|
||||
journeys: {
|
||||
launch: {
|
||||
'renderer.initialBytes': { value: 2750491, unit: 'bytes' },
|
||||
spawnToFirstCardMs: {
|
||||
value: 1000,
|
||||
unit: 'ms',
|
||||
toleranceRatio: 1.25,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const summaryWith = (initialBytes, wallClockMs = 1000) => ({
|
||||
journeys: {
|
||||
launch: {
|
||||
counters: { 'renderer.initialBytes': initialBytes },
|
||||
wallClock: { spawnToFirstCardMs: wallClockMs },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let workDir;
|
||||
|
||||
before(async () => {
|
||||
workDir = await mkdtemp(path.join(os.tmpdir(), 'check-journey-ratchet-'));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await rm(workDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('a counter equal to its baseline passes', () => {
|
||||
const result = compareToBaselines({
|
||||
baselines,
|
||||
summary: summaryWith(2750491),
|
||||
});
|
||||
assert.deepEqual(result.failures, []);
|
||||
assert.deepEqual(result.tightenable, []);
|
||||
assert.equal(result.passed.length, 2);
|
||||
assert.match(
|
||||
result.passed[0],
|
||||
/launch\/renderer\.initialBytes: 2,750,491 bytes within baseline 2,750,491/
|
||||
);
|
||||
});
|
||||
|
||||
test('a counter one byte above its baseline fails with the delta', () => {
|
||||
const result = compareToBaselines({
|
||||
baselines,
|
||||
summary: summaryWith(2750492),
|
||||
});
|
||||
assert.equal(result.failures.length, 1);
|
||||
assert.match(
|
||||
result.failures[0],
|
||||
/launch\/renderer\.initialBytes: 2,750,492 bytes exceeds baseline 2,750,491 by 1 bytes/
|
||||
);
|
||||
assert.match(result.failures[0], /baselines only move down/);
|
||||
});
|
||||
|
||||
test('a counter below its baseline passes and asks to tighten', () => {
|
||||
const result = compareToBaselines({
|
||||
baselines,
|
||||
summary: summaryWith(2600000),
|
||||
});
|
||||
assert.deepEqual(result.failures, []);
|
||||
assert.equal(result.tightenable.length, 1);
|
||||
assert.match(
|
||||
result.tightenable[0],
|
||||
/below baseline 2,750,491 by 150,491 bytes/
|
||||
);
|
||||
assert.match(
|
||||
result.tightenable[0],
|
||||
new RegExp(DEFAULT_BASELINES_PATH.replace(/\//g, '\\/'))
|
||||
);
|
||||
});
|
||||
|
||||
test('wall-clock entries fail only above value × toleranceRatio', () => {
|
||||
const within = compareToBaselines({
|
||||
baselines,
|
||||
summary: summaryWith(2750491, 1250),
|
||||
});
|
||||
assert.deepEqual(within.failures, []);
|
||||
assert.match(
|
||||
within.passed[1],
|
||||
/1,250 ms within 1,250 \(baseline 1,000 × 1\.25\)/
|
||||
);
|
||||
|
||||
const above = compareToBaselines({
|
||||
baselines,
|
||||
summary: summaryWith(2750491, 1251),
|
||||
});
|
||||
assert.equal(above.failures.length, 1);
|
||||
assert.match(
|
||||
above.failures[0],
|
||||
/spawnToFirstCardMs: 1,251 ms exceeds 1,250 \(baseline 1,000 × 1\.25\) by 1 ms/
|
||||
);
|
||||
|
||||
const below = compareToBaselines({
|
||||
baselines,
|
||||
summary: summaryWith(2750491, 900),
|
||||
});
|
||||
assert.equal(below.tightenable.length, 1);
|
||||
assert.match(
|
||||
below.tightenable[0],
|
||||
/spawnToFirstCardMs: 900 ms is below baseline 1,000/
|
||||
);
|
||||
});
|
||||
|
||||
test('a baseline without a measurement fails instead of being skipped', () => {
|
||||
const result = compareToBaselines({
|
||||
baselines,
|
||||
summary: { journeys: { launch: { counters: {} } } },
|
||||
});
|
||||
assert.equal(result.failures.length, 2);
|
||||
assert.match(
|
||||
result.failures[0],
|
||||
/renderer\.initialBytes: baseline 2,750,491 bytes has no measurement/
|
||||
);
|
||||
assert.match(
|
||||
result.failures[0],
|
||||
/cannot be bypassed by dropping a measurement/
|
||||
);
|
||||
});
|
||||
|
||||
test('a value in the wrong summary section counts as missing', () => {
|
||||
const counterInWallClock = compareToBaselines({
|
||||
baselines,
|
||||
summary: {
|
||||
journeys: {
|
||||
launch: {
|
||||
counters: {},
|
||||
wallClock: {
|
||||
'renderer.initialBytes': 2750491,
|
||||
spawnToFirstCardMs: 1000,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(counterInWallClock.failures.length, 1);
|
||||
assert.match(
|
||||
counterInWallClock.failures[0],
|
||||
/renderer\.initialBytes: baseline 2,750,491 bytes has no measurement under journeys\.launch\.counters/
|
||||
);
|
||||
|
||||
const wallClockInCounters = compareToBaselines({
|
||||
baselines,
|
||||
summary: {
|
||||
journeys: {
|
||||
launch: {
|
||||
counters: {
|
||||
'renderer.initialBytes': 2750491,
|
||||
spawnToFirstCardMs: 1000,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(wallClockInCounters.failures.length, 1);
|
||||
assert.match(
|
||||
wallClockInCounters.failures[0],
|
||||
/spawnToFirstCardMs: baseline 1,000 ms has no measurement under journeys\.launch\.wallClock/
|
||||
);
|
||||
});
|
||||
|
||||
test('an empty or malformed summary fails every baseline', () => {
|
||||
assert.equal(
|
||||
compareToBaselines({ baselines, summary: {} }).failures.length,
|
||||
2
|
||||
);
|
||||
assert.equal(
|
||||
compareToBaselines({ baselines, summary: null }).failures.length,
|
||||
2
|
||||
);
|
||||
const nonNumeric = compareToBaselines({
|
||||
baselines,
|
||||
summary: {
|
||||
journeys: {
|
||||
launch: { counters: { 'renderer.initialBytes': '2750491' } },
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.match(nonNumeric.failures[0], /"2750491" is not a finite number/);
|
||||
});
|
||||
|
||||
test('a measured counter without a baseline is reported but does not fail', () => {
|
||||
const summary = summaryWith(2750491);
|
||||
summary.journeys.launch.counters['renderer.cdTicksToFirstCard'] = 12;
|
||||
summary.journeys.search = { counters: { sqlStatementsPerKeystroke: 3 } };
|
||||
const result = compareToBaselines({ baselines, summary });
|
||||
assert.deepEqual(result.failures, []);
|
||||
assert.deepEqual(
|
||||
result.unbaselined.map((line) => line.split(':')[0]),
|
||||
[
|
||||
'launch/renderer.cdTicksToFirstCard',
|
||||
'search/sqlStatementsPerKeystroke',
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
test('an empty baselines file fails instead of passing vacuously', () => {
|
||||
const result = compareToBaselines({
|
||||
baselines: { journeys: {} },
|
||||
summary: summaryWith(2750491),
|
||||
});
|
||||
assert.equal(result.failures.length, 1);
|
||||
assert.match(result.failures[0], /No baselines were checked/);
|
||||
assert.match(
|
||||
formatResult(result),
|
||||
/Journey ratchet failed: 1 of 1 baselines exceeded/
|
||||
);
|
||||
});
|
||||
|
||||
test('--only restricts the check to the named baselines', () => {
|
||||
const result = compareToBaselines({
|
||||
baselines,
|
||||
summary: {
|
||||
journeys: {
|
||||
launch: { counters: { 'renderer.initialBytes': 2750491 } },
|
||||
},
|
||||
},
|
||||
only: ['launch/renderer.initialBytes'],
|
||||
});
|
||||
assert.deepEqual(result.failures, []);
|
||||
assert.equal(result.passed.length, 1);
|
||||
assert.match(result.passed[0], /launch\/renderer\.initialBytes/);
|
||||
});
|
||||
|
||||
test('--only naming a missing baseline fails rather than checking nothing', () => {
|
||||
const result = compareToBaselines({
|
||||
baselines,
|
||||
summary: summaryWith(2750491),
|
||||
only: ['launch/does.notExist'],
|
||||
});
|
||||
assert.equal(result.failures.length, 2);
|
||||
assert.match(
|
||||
result.failures[0],
|
||||
/launch\/does\.notExist: --only names a baseline that does not exist/
|
||||
);
|
||||
assert.match(result.failures[1], /No baselines were checked/);
|
||||
});
|
||||
|
||||
test('rejects malformed baseline files', () => {
|
||||
assert.throws(() => validateBaselines({}), /object with a "journeys" map/);
|
||||
assert.throws(
|
||||
() => validateBaselines({ journeys: { launch: [] } }),
|
||||
/journey "launch" must map/
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
validateBaselines({
|
||||
journeys: { launch: { x: { value: 'big' } } },
|
||||
}),
|
||||
/Baseline launch\/x needs a finite numeric "value"/
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
validateBaselines({
|
||||
journeys: { launch: { x: { value: 1, toleranceRatio: 0.5 } } },
|
||||
}),
|
||||
/invalid "toleranceRatio"/
|
||||
);
|
||||
});
|
||||
|
||||
test('formats a summary line for both outcomes', () => {
|
||||
const ok = formatResult(
|
||||
compareToBaselines({ baselines, summary: summaryWith(2600000) })
|
||||
);
|
||||
assert.match(ok, /^ok {7}launch\/spawnToFirstCardMs/m);
|
||||
assert.match(ok, /^tighten {2}launch\/renderer\.initialBytes/m);
|
||||
assert.match(
|
||||
ok,
|
||||
/Journey ratchet OK: 2 baselines checked, 1 can be tightened\.$/
|
||||
);
|
||||
|
||||
const failed = formatResult(
|
||||
compareToBaselines({ baselines, summary: summaryWith(3000000) })
|
||||
);
|
||||
assert.match(failed, /^FAIL {5}launch\/renderer\.initialBytes/m);
|
||||
assert.match(
|
||||
failed,
|
||||
/Journey ratchet failed: 1 of 2 baselines exceeded\.$/
|
||||
);
|
||||
});
|
||||
|
||||
test('parses CLI arguments and requires --summary', () => {
|
||||
assert.deepEqual(parseArgs(['--summary', 's.json']), {
|
||||
summary: 's.json',
|
||||
baselines: DEFAULT_BASELINES_PATH,
|
||||
only: [],
|
||||
});
|
||||
assert.deepEqual(
|
||||
parseArgs([
|
||||
'--',
|
||||
'--summary=s.json',
|
||||
'--baselines=b.json',
|
||||
'--only',
|
||||
'launch/a',
|
||||
'--only=search/b',
|
||||
]),
|
||||
{
|
||||
summary: 's.json',
|
||||
baselines: 'b.json',
|
||||
only: ['launch/a', 'search/b'],
|
||||
}
|
||||
);
|
||||
assert.throws(
|
||||
() => parseArgs(['--summary', 's', '--only', 'launch']),
|
||||
/--only expects <journey>\/<counter>/
|
||||
);
|
||||
assert.throws(
|
||||
() => parseArgs(['--summary', 's', '--only']),
|
||||
/Missing value for --only/
|
||||
);
|
||||
assert.throws(
|
||||
() => parseArgs([]),
|
||||
/--summary <journey-summary\.json> is required/
|
||||
);
|
||||
assert.throws(
|
||||
() => parseArgs(['--summary']),
|
||||
/Missing value for --summary/
|
||||
);
|
||||
assert.throws(
|
||||
() => parseArgs(['--summary', 's', '--strict']),
|
||||
/Unknown argument: --strict/
|
||||
);
|
||||
});
|
||||
|
||||
test('the committed baselines file is valid and every entry names its evidence fields', async () => {
|
||||
const committed = JSON.parse(
|
||||
await readFile(committedBaselinesPath, 'utf8')
|
||||
);
|
||||
validateBaselines(committed);
|
||||
assert.ok(
|
||||
committed.journeys.launch?.['renderer.initialBytes'],
|
||||
'the launch/renderer.initialBytes baseline must stay committed; the CI ratchet guards it'
|
||||
);
|
||||
for (const entries of Object.values(committed.journeys)) {
|
||||
for (const entry of Object.values(entries)) {
|
||||
assert.match(entry.updatedAt, /^\d{4}-\d{2}-\d{2}$/);
|
||||
assert.ok(
|
||||
'evidencePr' in entry,
|
||||
'evidencePr must be present (null before the first PR)'
|
||||
);
|
||||
assert.equal(typeof entry.measuredWith, 'string');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function runCli(summary, extraBaselines = baselines) {
|
||||
const summaryPath = path.join(
|
||||
workDir,
|
||||
`summary-${Date.now()}-${Math.random()}.json`
|
||||
);
|
||||
const baselinesPath = path.join(
|
||||
workDir,
|
||||
`baselines-${Date.now()}-${Math.random()}.json`
|
||||
);
|
||||
await writeFile(summaryPath, JSON.stringify(summary));
|
||||
await writeFile(baselinesPath, JSON.stringify(extraBaselines));
|
||||
return spawnSync(
|
||||
process.execPath,
|
||||
[scriptPath, '--summary', summaryPath, '--baselines', baselinesPath],
|
||||
{ encoding: 'utf8' }
|
||||
);
|
||||
}
|
||||
|
||||
test('CLI exits 0 when within baselines and 1 when a counter grew', async () => {
|
||||
const ok = await runCli(summaryWith(2750491));
|
||||
assert.equal(ok.status, 0, ok.stderr);
|
||||
assert.match(ok.stdout, /Journey ratchet OK/);
|
||||
|
||||
const grew = await runCli(summaryWith(2750492));
|
||||
assert.equal(grew.status, 1);
|
||||
assert.match(grew.stderr, /FAIL {5}launch\/renderer\.initialBytes/);
|
||||
});
|
||||
|
||||
test('CLI exits 1 with a readable message when the summary is missing', () => {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[scriptPath, '--summary', path.join(workDir, 'nope.json')],
|
||||
{ encoding: 'utf8' }
|
||||
);
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(
|
||||
result.stderr,
|
||||
/check-journey-ratchet: Cannot read journey summary at/
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"version": 1,
|
||||
"journeys": {
|
||||
"launch": {
|
||||
"renderer.initialBytes": {
|
||||
"value": 2715087,
|
||||
"unit": "bytes",
|
||||
"updatedAt": "2026-09-26",
|
||||
"evidencePr": 1695,
|
||||
"measuredWith": "pnpm nx build web && pnpm run perf:initial-bytes"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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&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/);
|
||||
});
|
||||
@@ -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 tools/performance/check-journey-ratchet.test.mjs tools/performance/check-baseline-direction.test.mjs",
|
||||
"cwd": "{workspaceRoot}"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"inputs": [
|
||||
"default",
|
||||
"{workspaceRoot}/eslint.config.mjs",
|
||||
"{workspaceRoot}/tools/eslint-rules/**/*",
|
||||
"{workspaceRoot}/tools/eslint/**/*"
|
||||
],
|
||||
"command": "eslint \"tools/performance/*.mjs\""
|
||||
}
|
||||
}
|
||||
}
|
||||