Merge remote-tracking branch 'origin/master' into claude/parental-control-feature-31dde2

# Conflicts:
#	apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Change app language.png
#	apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Change app theme.png
#	apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Change video player.png
#	apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Check settings page.png
#	apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Deep links open one section page and unknown sections redirect.png
#	apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Leaving with unsaved edits asks for confirmation.png
#	apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Save-and-leave persists the staged edit.png
#	apps/web-e2e/dist/.playwright/apps/web-e2e/screenshots/settings/@settings @web Unsaved bar survives section switches and discard reverts.png
This commit is contained in:
4gray committed 2026-09-26 14:18:36 +02:00
commit 1d945e91b1
46 files changed
+2647 -82

No files matched your search

+8
View File
@@ -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.
+79
View File
@@ -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
+57 -5
View File
@@ -1,6 +1,12 @@
name: Publish Snap after public release
on:
workflow_dispatch:
inputs:
tag:
description: Existing public stable release tag to retry (for example v0.24.0)
required: true
type: string
release:
types:
- published
@@ -11,7 +17,7 @@ permissions:
jobs:
verify-snap:
name: Verify public-release Snap assets
if: ${{ startsWith(github.event.release.tag_name, 'v') && github.event.release.draft == false }}
if: ${{ (github.event_name == 'release' && startsWith(github.event.release.tag_name, 'v') && github.event.release.draft == false) || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/master') }}
runs-on: ubuntu-latest
timeout-minutes: 45
env:
@@ -20,10 +26,34 @@ jobs:
receipt-sha256: ${{ steps.bind-transfer.outputs.receipt-sha256 }}
steps:
- name: Resolve public release
id: resolve-release
shell: bash
env:
GH_TOKEN: ${{ github.token }}
REQUESTED_TAG: ${{ inputs.tag || github.event.release.tag_name }}
EVENT_RELEASE_ID: ${{ github.event.release.id }}
run: |
set -euo pipefail
[[ "${REQUESTED_TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]
RELEASE_JSON="${RUNNER_TEMP}/snap-public-release.json"
gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${REQUESTED_TAG}" > "${RELEASE_JSON}"
/usr/bin/jq --exit-status --arg tag "${REQUESTED_TAG}" '
.tag_name == $tag and .draft == false and .prerelease == false and
(.published_at | type == "string" and length > 0) and
(.id | type == "number" and . > 0 and . == floor)
' "${RELEASE_JSON}" > /dev/null
RELEASE_ID="$(/usr/bin/jq --raw-output '.id' "${RELEASE_JSON}")"
if [[ -n "${EVENT_RELEASE_ID}" ]]; then
test "${RELEASE_ID}" = "${EVENT_RELEASE_ID}"
fi
printf 'tag=%s\nrelease-id=%s\n' "${REQUESTED_TAG}" "${RELEASE_ID}" >> "${GITHUB_OUTPUT}"
- name: Checkout released tooling
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
ref: ${{ github.event.release.tag_name }}
ref: refs/tags/${{ steps.resolve-release.outputs.tag }}
persist-credentials: false
- name: Install release source verifier
@@ -41,13 +71,14 @@ jobs:
shell: bash
env:
GH_TOKEN: ${{ github.token }}
RELEASE_ID: ${{ steps.resolve-release.outputs.release-id }}
run: |
set -euo pipefail
gh api \
--paginate \
--slurp \
"repos/${GITHUB_REPOSITORY}/releases/${{ github.event.release.id }}/assets?per_page=100" \
"repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}/assets?per_page=100" \
> "${RUNNER_TEMP}/snap-release-assets.json"
node tools/packaging/release-snap-assets.cjs select \
--assets-json "${RUNNER_TEMP}/snap-release-assets.json" \
@@ -133,7 +164,7 @@ jobs:
publish-snap:
name: Publish verified public-release Snap to edge
needs: verify-snap
if: ${{ needs.verify-snap.result == 'success' && startsWith(github.event.release.tag_name, 'v') && github.event.release.draft == false }}
if: ${{ needs.verify-snap.result == 'success' }}
runs-on: ubuntu-latest
timeout-minutes: 20
@@ -238,6 +269,26 @@ jobs:
sudo find "${SEALED_ASSET_DIRECTORY}" -type f -exec chmod 0444 {} +
sudo chmod 0555 "${SEALED_ASSET_PARENT}"
- name: Prepare Snapcraft upload workspace
shell: bash
run: |
set -euo pipefail
VERIFIED_ASSET_DIRECTORY="/var/lib/iptvnator-snap-release/assets"
UPLOAD_DIRECTORY="/var/lib/iptvnator-snap-upload"
sudo test ! -e "${UPLOAD_DIRECTORY}"
sudo install -d -m 0700 -o root -g root "${UPLOAD_DIRECTORY}"
shopt -s nullglob dotglob
SNAP_FILES=("${VERIFIED_ASSET_DIRECTORY}"/*.snap)
test "${#SNAP_FILES[@]}" -gt 0
for SNAP_FILE in "${SNAP_FILES[@]}"; do
sudo ln -- "${SNAP_FILE}" "${UPLOAD_DIRECTORY}/${SNAP_FILE##*/}"
done
# Snapcraft extracts metadata beside the input file. Root-owned
# hard links remain read-only; the sticky bit prevents replacement.
sudo chmod 1777 "${UPLOAD_DIRECTORY}"
shopt -u nullglob dotglob
- name: Install Snapcraft
shell: bash
run: |
@@ -253,6 +304,7 @@ jobs:
set -euo pipefail
VERIFIED_ASSET_DIRECTORY="/var/lib/iptvnator-snap-release/assets"
UPLOAD_DIRECTORY="/var/lib/iptvnator-snap-upload"
STORE_CREDENTIALS="${SNAPCRAFT_STORE_CREDENTIALS}"
unset SNAPCRAFT_STORE_CREDENTIALS
shopt -s nullglob dotglob
@@ -263,7 +315,7 @@ jobs:
echo "Publishing public release asset: ${SNAP_NAME}"
# Candidate/stable promotion is manual after installed-Snap frame-copy and missing-runtime fallback smoke.
# GitHub Actions never promotes automatically.
SNAPCRAFT_STORE_CREDENTIALS="${STORE_CREDENTIALS}" /snap/bin/snapcraft upload --release=edge "${SNAP_FILE}"
SNAPCRAFT_STORE_CREDENTIALS="${STORE_CREDENTIALS}" /snap/bin/snapcraft upload --release=edge "${UPLOAD_DIRECTORY}/${SNAP_NAME}"
done
unset STORE_CREDENTIALS
shopt -u nullglob dotglob
+11
View File
@@ -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).
## Disclaimer
**IPTVnator doesn't provide any playlists or other digital content.**
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 980 KiB

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 95 KiB

+1
View File
@@ -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',
+123
View File
@@ -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);
});
});
+117 -42
View File
@@ -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);
}
}
}
+8 -2
View File
@@ -33,6 +33,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';
@@ -146,6 +147,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: {
@@ -233,12 +239,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
);
+5 -1
View File
@@ -39,6 +39,7 @@ import {
Theme,
createDevLogger,
} from '@iptvnator/shared/interfaces';
import { AppDateLocaleService } from './app-date-locales';
import { SettingsService } from './services/settings.service';
import { ParentalLockEnforcementService } from './services/parental-lock-enforcement.service';
import { PlaybackKeepAwakeService } from './services/playback-keep-awake.service';
@@ -71,6 +72,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);
@@ -160,7 +162,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
);
+2 -2
View File
@@ -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',
};
+2 -2
View File
@@ -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',
};
+2 -2
View File
@@ -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',
};
+8 -5
View File
@@ -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
+12
View File
@@ -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;
+1 -1
View File
@@ -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`
+146
View File
@@ -0,0 +1,146 @@
# Performance journeys and the CI ratchet
IPTVnator measures performance through a small set of everyday user journeys.
Each journey has deterministic counters that are asserted exactly, and
wall-clock timings that are recorded as evidence. Counters are ratcheted in CI:
a committed baseline may only be lowered, and only with the measured output as
evidence. This document is the contract for that loop; `tools/performance/`
holds the scripts.
## Journeys
| Journey | Start | End |
| ---------------- | -------------------------------------------- | ----------------------------------------------------------------------------- |
| J1 `launch` | Electron process spawn | first playlist or portal card rendered on `/workspace`, inline splash removed |
| J2 `open-source` | click on a portal card | live category list and first channel page painted |
| J3 `playback` | click on a channel | HTML5 `playing` event |
| J4 `search` | six-character query typed into global search | results list settled |
Only the J1 counter `renderer.initialBytes` is instrumented today. The other
journeys and counters follow the plan in `.plans/` and are added one thread at
a time; each thread names its journey and counter in the PR description.
## `renderer.initialBytes`
The bytes a browser fetches before Angular can bootstrap, read from the built
`dist/apps/web/index.html`:
- `index.html` itself,
- every same-origin `<script src>`, including `assets/app-config.js`,
- every `<link rel="stylesheet">`,
- every `<link rel="modulepreload">` chunk.
Manifest, icons, external URLs, 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.
+15
View File
@@ -439,6 +439,21 @@ candidate/stable promotion remain manual (see
draft during artifact verification, then publish it in a follow-up commit and
verify the website deployment.
If a Store upload fails after publication, run `publish-snap.yaml` from
`master` with its `tag` input set to the existing public stable tag, for example
`gh workflow run publish-snap.yaml --ref master -f tag=v0.24.0`. The workflow
resolves the public release through the API, rejects drafts/prereleases and
invalid tags, and repeats the full released-tooling, asset and source-archive
verification before uploading to `edge`. Do not move the release tag, rebuild
its assets or republish the GitHub release to retry a Store upload.
Snapcraft extracts metadata into a temporary sibling of the input `.snap`.
The publisher therefore gives it root-owned read-only hard links in a separate
root-owned sticky directory. Temporary siblings are writable, while the sticky
bit prevents the unprivileged uploader from replacing the root-owned inputs.
The original verified snapshot stays sealed; upload filenames are enumerated
only from that snapshot, never from the writable scratch directory.
## Validation
```bash
+18
View File
@@ -153,6 +153,24 @@ 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
```
`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). The contract, what counts and how to add a counter are in the
[performance journeys](performance-journeys.md) document.
## Logging
Runtime playback and EPG debug logs should use the existing logger or trace
+1
View File
@@ -14,6 +14,7 @@ are not prerequisites for reading repository contracts.
| Bootstrap, project placement, dependencies, aliases and lint configuration; root Nx config and project-local project.json files | [Nx boundaries](../architecture/nx-workspace-boundaries.md), [security overrides](../architecture/dependency-security-overrides.md) | [Nx architecture](../../.codex/skills/iptvnator-nx-architecture/SKILL.md) |
| Angular conventions; docs and skills maintenance | [Agent workflow](../development/agent-workflow.md) | Use the area's skill below |
| Unit, E2E, lint and coverage; `tools/coverage` | [Validation map](../architecture/validation-map.md) | Use the area's validation section |
| Performance journeys, counters and the CI ratchet; `tools/performance` | [Performance journeys](../architecture/performance-journeys.md) | Read the contract directly |
| Electron entry/events/preload and CDP; `apps/electron-backend` | [Debugging and trace flags](../development/electron-debugging.md), [Electron security](../architecture/electron-security.md) | Use the available global electron skill for automation |
| Releases, notes, screenshots, native assets, Linux manager metadata; `tools/release` | [Release pipeline](../architecture/release-pipeline.md), [note format](../../.changes/README.md) | [Release notes](../../.codex/skills/release-notes/SKILL.md), [release cut](../../.codex/skills/release-cut/SKILL.md) |
+1
View File
@@ -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 ?? ''
)
);
+4
View File
@@ -71,6 +71,10 @@
"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: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",
+6
View File
@@ -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",
+11 -3
View File
@@ -397,8 +397,11 @@ CI. This affects only Chromium's software-renderer admission; the manifest,
hash, loader, and helper probes still fail closed, and `--no-sandbox` remains
root-only.
Snap publication is a separate `release.published` workflow for public `v*`
GitHub releases. It verifies that the public release already contains at least
Snap publication is a separate `release.published` workflow for public stable
GitHub releases, with a `workflow_dispatch` retry from `master` for an existing
public stable tag. Both paths resolve the release through the API before
checking out its tag; draft, prerelease and mismatched event IDs are rejected.
It verifies that the public release already contains at least
one Snap and exactly one non-empty
`linux-frame-copy-runtime-sources.tar.xz` before uploading anything. The
release verifier hashes the downloaded archive, checks its clean released
@@ -430,7 +433,12 @@ The dependent publish job runs on a bounded GitHub-hosted `ubuntu-latest`
runner with no checkout or release-tag code. It verifies that separate digest,
the exact receipt schema, every asset size/hash, and the expected regular-file
layout, rejects links and extras, root-seals the transferred data again, and
installs the official stable Snapcraft snap. Only its final fixed shell step
installs the official stable Snapcraft snap. Snapcraft creates temporary
metadata-extraction siblings beside its input, so the publisher creates
root-owned read-only hard links in a separate root-owned sticky directory.
The uploader can create temporary siblings but cannot modify or replace those
inputs; the original sealed snapshot supplies the upload filename list.
Only its final fixed shell step
receives the Store credential; it executes no released code, resolves no PATH
command, and passes the credential only to each exact
`/snap/bin/snapcraft upload --release=edge` process. GitHub credentials remain
+150 -3
View File
@@ -3,6 +3,7 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { spawnSync } from 'node:child_process';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { parse } from 'yaml';
import {
@@ -99,6 +100,152 @@ function assertStepRejectedByBothPolicies(stepSource) {
}
}
test('recovery resolves only an existing public stable release before checkout', (t) => {
const workflow = parse(fs.readFileSync(publishWorkflowPath, 'utf8'));
const steps = workflow.jobs['verify-snap'].steps;
const resolve = steps.find((step) => step.id === 'resolve-release');
assert.ok(
resolve,
'recovery must resolve the public release before checkout'
);
assert.ok(steps.indexOf(resolve) < steps.findIndex((step) => step.uses));
assert.equal(workflow.on.workflow_dispatch.inputs.tag.required, true);
const directory = fs.mkdtempSync(
path.join(os.tmpdir(), 'snap-release-resolution-')
);
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
fs.writeFileSync(
path.join(directory, 'gh'),
'#!/bin/sh\ncat "$RELEASE_FIXTURE"\n',
{ mode: 0o755 }
);
const output = path.join(directory, 'output');
const fixture = path.join(directory, 'release.json');
for (const [patch, tag, eventId, succeeds] of [
[{}, 'v0.24.0', '', true],
[{}, 'v0.24.0', '123', true],
[{ draft: true }, 'v0.24.0', '', false],
[{ prerelease: true }, 'v0.24.0', '', false],
[{ tag_name: 'v0.25.0' }, 'v0.24.0', '', false],
[{}, 'v0.24.0', '456', false],
[{}, 'v0.24.0; touch injected', '', false],
[{}, '../../master', '', false],
]) {
fs.writeFileSync(
fixture,
JSON.stringify({
id: 123,
tag_name: 'v0.24.0',
draft: false,
prerelease: false,
published_at: '2026-09-24T06:58:11Z',
...patch,
})
);
fs.writeFileSync(output, '');
const result = spawnSync(
'bash',
['-e', '-o', 'pipefail', '-c', resolve.run],
{
encoding: 'utf8',
env: {
...process.env,
PATH: `${directory}:${process.env.PATH}`,
RELEASE_FIXTURE: fixture,
RUNNER_TEMP: directory,
GITHUB_OUTPUT: output,
GITHUB_REPOSITORY: '4gray/iptvnator',
REQUESTED_TAG: tag,
EVENT_RELEASE_ID: eventId,
},
}
);
assert.equal(result.status === 0, succeeds, result.stderr);
if (succeeds) {
assert.match(
fs.readFileSync(output, 'utf8'),
/tag=v0\.24\.0\nrelease-id=123\n/
);
} else {
assert.equal(fs.readFileSync(output, 'utf8'), '');
}
}
});
test(
'Snapcraft scratch siblings are writable while upload payloads cannot be replaced',
{ skip: process.platform !== 'linux' },
(t) => {
const workflow = parse(fs.readFileSync(publishWorkflowPath, 'utf8'));
const step = workflow.jobs['publish-snap'].steps.find(
(entry) => entry.name === 'Prepare Snapcraft upload workspace'
);
assert.ok(
step,
'Snapcraft needs a writable sibling directory for metadata extraction'
);
const canElevate =
process.getuid() === 0 ||
spawnSync('sudo', ['-n', 'true']).status === 0;
const canDropPrivileges =
spawnSync('/usr/bin/setpriv', ['--version']).status === 0;
if (!canElevate || !canDropPrivileges) {
const reason =
'Snap upload permission integration requires root or passwordless sudo and /usr/bin/setpriv';
assert.ok(!process.env.CI, reason);
t.skip(reason);
return;
}
const directory = fs.mkdtempSync(
path.join(os.tmpdir(), 'snap-upload-permissions-')
);
const asRoot = (command) =>
spawnSync(
process.getuid() === 0 ? 'bash' : 'sudo',
process.getuid() === 0
? ['-e', '-c', command]
: ['-n', 'bash', '-e', '-c', command],
{ encoding: 'utf8' }
);
t.after(() => asRoot(`rm -rf '${directory}'`));
const sealed = path.join(directory, 'sealed');
const upload = path.join(directory, 'upload');
const setup = asRoot(
`chmod 0755 '${directory}'; mkdir '${sealed}'; printf payload > '${sealed}/package.snap'; chown -R root:root '${sealed}'; chmod 0444 '${sealed}/package.snap'; chmod 0555 '${sealed}'`
);
assert.equal(setup.status, 0, setup.stderr);
const prepare = asRoot(
step.run
.replaceAll('/var/lib/iptvnator-snap-release/assets', sealed)
.replaceAll('/var/lib/iptvnator-snap-upload', upload)
.replaceAll('sudo ', '')
);
assert.equal(prepare.status, 0, prepare.stderr);
const checks = `
const fs = require('node:fs');
const assert = require('node:assert/strict');
const sealed = ${JSON.stringify(sealed)};
const upload = ${JSON.stringify(upload)};
assert.throws(() => fs.mkdtempSync(sealed + '/tmp-'), { code: 'EACCES' });
const scratch = fs.mkdtempSync(upload + '/tmp-');
fs.rmdirSync(scratch);
assert.equal(fs.statSync(upload).uid, 0);
assert.equal(fs.statSync(upload).mode & 0o1777, 0o1777);
assert.equal(fs.statSync(upload + '/package.snap').ino, fs.statSync(sealed + '/package.snap').ino);
assert.throws(() => fs.writeFileSync(upload + '/package.snap', 'changed'), { code: 'EACCES' });
assert.throws(() => fs.unlinkSync(upload + '/package.snap'), { code: 'EPERM' });
fs.writeFileSync(upload + '/replacement', 'changed');
assert.throws(() => fs.renameSync(upload + '/replacement', upload + '/package.snap'), { code: 'EPERM' });
assert.equal(fs.readFileSync(sealed + '/package.snap', 'utf8'), 'payload');
`;
// Nobody models an unprivileged uploader even when the test runs in a root container.
const result = asRoot(
`/usr/bin/setpriv --reuid=65534 --regid=65534 --clear-groups '${process.execPath}' -e '${checks.replaceAll("'", "'\\''")}'`
);
assert.equal(result.status, 0, result.stderr);
}
);
test('publishes Snap only after a public v-tag release contains binary and source assets', () => {
assert.equal(
fs.existsSync(publishWorkflowPath),
@@ -134,8 +281,8 @@ test('publishes Snap only after a public v-tag release contains binary and sourc
assert.match(workflowText, /release-snap-assets\.cjs verify/);
assertPublishSnapWorkflowPolicy(workflowText);
const disabledWorkflow = workflowText.replace(
'github.event.release.draft == false }}',
'github.event.release.draft == false && false }}'
'github.event.release.draft == false)',
'github.event.release.draft == false && false)'
);
assert.notEqual(disabledWorkflow, workflowText);
assert.doesNotThrow(() => parse(disabledWorkflow));
@@ -286,7 +433,7 @@ test('rejects Snap uploads that target candidate or stable channels', () => {
test('rejects edge upload text in non-executing shell contexts', () => {
const workflowText = fs.readFileSync(publishWorkflowPath, 'utf8');
const edgeUpload =
'SNAPCRAFT_STORE_CREDENTIALS="${STORE_CREDENTIALS}" /snap/bin/snapcraft upload --release=edge "${SNAP_FILE}"';
'SNAPCRAFT_STORE_CREDENTIALS="${STORE_CREDENTIALS}" /snap/bin/snapcraft upload --release=edge "${UPLOAD_DIRECTORY}/${SNAP_NAME}"';
const blockIndent = ' '.repeat(18);
for (const replacement of [
`cat <<123\n${edgeUpload}\n123`,
@@ -1731,6 +1731,7 @@ test('publish workflow installs the source verifier and binds the release tag re
assert.equal(Object.hasOwn(publishJob.env ?? {}, 'GH_TOKEN'), false);
assert.deepEqual(selectStep.env, {
GH_TOKEN: '${{ github.token }}',
RELEASE_ID: '${{ steps.resolve-release.outputs.release-id }}',
});
assert.deepEqual(downloadStep.env, {
GH_TOKEN: '${{ github.token }}',
@@ -1,6 +1,57 @@
import assert from 'node:assert/strict';
import { parse } from 'yaml';
const PUBLISH_UPLOAD_WORKSPACE_STEP_CONTRACT = Object.freeze({
name: 'Prepare Snapcraft upload workspace',
shell: 'bash',
run: [
'set -euo pipefail',
'',
'VERIFIED_ASSET_DIRECTORY="/var/lib/iptvnator-snap-release/assets"',
'UPLOAD_DIRECTORY="/var/lib/iptvnator-snap-upload"',
'sudo test ! -e "${UPLOAD_DIRECTORY}"',
'sudo install -d -m 0700 -o root -g root "${UPLOAD_DIRECTORY}"',
'shopt -s nullglob dotglob',
'SNAP_FILES=("${VERIFIED_ASSET_DIRECTORY}"/*.snap)',
'test "${#SNAP_FILES[@]}" -gt 0',
'for SNAP_FILE in "${SNAP_FILES[@]}"; do',
' sudo ln -- "${SNAP_FILE}" "${UPLOAD_DIRECTORY}/${SNAP_FILE##*/}"',
'done',
'# Snapcraft extracts metadata beside the input file. Root-owned',
'# hard links remain read-only; the sticky bit prevents replacement.',
'sudo chmod 1777 "${UPLOAD_DIRECTORY}"',
'shopt -u nullglob dotglob',
'',
].join('\n'),
});
const PUBLISH_RESOLVE_STEP_CONTRACT = Object.freeze({
name: 'Resolve public release',
id: 'resolve-release',
shell: 'bash',
env: {
GH_TOKEN: '${{ github.token }}',
REQUESTED_TAG: '${{ inputs.tag || github.event.release.tag_name }}',
EVENT_RELEASE_ID: '${{ github.event.release.id }}',
},
run: [
'set -euo pipefail',
'',
'[[ "${REQUESTED_TAG}" =~ ^v[0-9]+\\.[0-9]+\\.[0-9]+$ ]]',
'RELEASE_JSON="${RUNNER_TEMP}/snap-public-release.json"',
'gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${REQUESTED_TAG}" > "${RELEASE_JSON}"',
'/usr/bin/jq --exit-status --arg tag "${REQUESTED_TAG}" \'',
' .tag_name == $tag and .draft == false and .prerelease == false and',
' (.published_at | type == "string" and length > 0) and',
' (.id | type == "number" and . > 0 and . == floor)',
'\' "${RELEASE_JSON}" > /dev/null',
'RELEASE_ID="$(/usr/bin/jq --raw-output \'.id\' "${RELEASE_JSON}")"',
'if [[ -n "${EVENT_RELEASE_ID}" ]]; then',
' test "${RELEASE_ID}" = "${EVENT_RELEASE_ID}"',
'fi',
'printf \'tag=%s\\nrelease-id=%s\\n\' "${REQUESTED_TAG}" "${RELEASE_ID}" >> "${GITHUB_OUTPUT}"',
'',
].join('\n'),
});
const PINNED_CHECKOUT_ACTION =
'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1';
const PINNED_UPLOAD_ARTIFACT_ACTION =
@@ -27,9 +78,8 @@ const BUILD_ACTION_ALLOWLIST = Object.freeze([
const VERIFY_JOB_ID = 'verify-snap';
const PUBLISH_JOB_ID = 'publish-snap';
const VERIFY_JOB_CONDITION =
"${{ startsWith(github.event.release.tag_name, 'v') && github.event.release.draft == false }}";
const PUBLISH_JOB_CONDITION =
"${{ needs.verify-snap.result == 'success' && startsWith(github.event.release.tag_name, 'v') && github.event.release.draft == false }}";
"${{ (github.event_name == 'release' && startsWith(github.event.release.tag_name, 'v') && github.event.release.draft == false) || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/master') }}";
const PUBLISH_JOB_CONDITION = "${{ needs.verify-snap.result == 'success' }}";
const VERIFIED_RELEASE_ARTIFACT_NAME = 'verified-snap-release-assets';
const PUBLISH_STEP_NAME = 'Publish all public-release snaps to edge';
const PUBLISH_CHECKOUT_STEP_NAME = 'Checkout released tooling';
@@ -37,7 +87,7 @@ const PUBLISH_CHECKOUT_STEP_CONTRACT = Object.freeze({
name: PUBLISH_CHECKOUT_STEP_NAME,
uses: PINNED_CHECKOUT_ACTION,
with: {
ref: '${{ github.event.release.tag_name }}',
ref: 'refs/tags/${{ steps.resolve-release.outputs.tag }}',
'persist-credentials': false,
},
});
@@ -217,6 +267,7 @@ const PUBLISH_STEP_CONTRACT = Object.freeze({
'set -euo pipefail',
'',
'VERIFIED_ASSET_DIRECTORY="/var/lib/iptvnator-snap-release/assets"',
'UPLOAD_DIRECTORY="/var/lib/iptvnator-snap-upload"',
'STORE_CREDENTIALS="${SNAPCRAFT_STORE_CREDENTIALS}"',
'unset SNAPCRAFT_STORE_CREDENTIALS',
'shopt -s nullglob dotglob',
@@ -227,7 +278,7 @@ const PUBLISH_STEP_CONTRACT = Object.freeze({
' echo "Publishing public release asset: ${SNAP_NAME}"',
' # Candidate/stable promotion is manual after installed-Snap frame-copy and missing-runtime fallback smoke.',
' # GitHub Actions never promotes automatically.',
' SNAPCRAFT_STORE_CREDENTIALS="${STORE_CREDENTIALS}" /snap/bin/snapcraft upload --release=edge "${SNAP_FILE}"',
' SNAPCRAFT_STORE_CREDENTIALS="${STORE_CREDENTIALS}" /snap/bin/snapcraft upload --release=edge "${UPLOAD_DIRECTORY}/${SNAP_NAME}"',
'done',
'unset STORE_CREDENTIALS',
'shopt -u nullglob dotglob',
@@ -404,8 +455,20 @@ export function assertPublishSnapWorkflowPolicy(workflowText) {
assertWorkflowExecutionShape(policyInputs);
assert.deepEqual(
workflow.on,
{ release: { types: ['published'] } },
'the publish workflow must retain its exact release trigger'
{
workflow_dispatch: {
inputs: {
tag: {
description:
'Existing public stable release tag to retry (for example v0.24.0)',
required: true,
type: 'string',
},
},
},
release: { types: ['published'] },
},
'the publish workflow must retain its public-release and explicit recovery triggers'
);
assert.deepEqual(
Object.keys(workflow).sort(),
@@ -491,6 +554,11 @@ export function assertPublishSnapWorkflowPolicy(workflowText) {
PUBLISH_JOB_CONDITION,
'the publish job must retain its exact verified-release condition'
);
assert.deepEqual(
verifyJob.steps.filter((step) => step.id === 'resolve-release'),
[PUBLISH_RESOLVE_STEP_CONTRACT],
'resolve and validate the public release before executing released tooling'
);
assert.deepEqual(
verifyJob.steps.filter(
(step) => step.name === PUBLISH_CHECKOUT_STEP_NAME
@@ -524,6 +592,7 @@ export function assertPublishSnapWorkflowPolicy(workflowText) {
[
PUBLISH_ARTIFACT_DOWNLOAD_STEP_CONTRACT,
PUBLISH_TRANSFER_VERIFY_STEP_CONTRACT,
PUBLISH_UPLOAD_WORKSPACE_STEP_CONTRACT,
PUBLISH_SNAPCRAFT_SETUP_STEP_CONTRACT,
PUBLISH_STEP_CONTRACT,
],
@@ -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/);
});
+297
View File
@@ -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/
);
});
+14
View File
@@ -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"
}
}
}
}
+269
View File
@@ -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&amp;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/);
});
+31
View File
@@ -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\""
}
}
}