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
No files matched your search
@@ -0,0 +1,8 @@
|
||||
---
|
||||
type: perf
|
||||
area: web
|
||||
---
|
||||
|
||||
The app now loads date and time formatting data only for the language you
|
||||
use instead of shipping all 18 languages in the startup bundle, so it has
|
||||
less to download and parse on every launch; English needs none at all.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
type: perf
|
||||
area: web
|
||||
---
|
||||
|
||||
The app no longer ships its whole `package.json` inside the startup bundle,
|
||||
only its version number, which trims about 11 KB from every launch.
|
||||
@@ -126,6 +126,85 @@ jobs:
|
||||
CI: true
|
||||
NX_TASKS_RUNNER_DYNAMIC_OUTPUT: false
|
||||
|
||||
initial-bytes-ratchet:
|
||||
name: Initial bytes ratchet
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v6.0.10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version-file: '.nvmrc'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
# The ratchet below compares a measurement with the baselines file
|
||||
# of the same commit, so it cannot see a change that grows the
|
||||
# payload and raises the baseline to match. Compare the file with
|
||||
# the revision this one is measured against instead: the target
|
||||
# branch for a pull request, the previous head for a master push,
|
||||
# master for a manual dispatch. Any raised limit, widened tolerance
|
||||
# or removed entry fails.
|
||||
- name: Refuse baseline increases against the previous revision
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
BASE_REF: ${{ github.base_ref }}
|
||||
BEFORE_SHA: ${{ github.event.before }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
case "$EVENT_NAME" in
|
||||
pull_request)
|
||||
git fetch --no-tags --depth=1 origin "$BASE_REF"
|
||||
;;
|
||||
push)
|
||||
if [ -z "$BEFORE_SHA" ] || [ "$BEFORE_SHA" = "0000000000000000000000000000000000000000" ]; then
|
||||
echo "No previous revision for this push; nothing to compare against."
|
||||
exit 0
|
||||
fi
|
||||
git fetch --no-tags --depth=1 origin "$BEFORE_SHA"
|
||||
;;
|
||||
*)
|
||||
git fetch --no-tags --depth=1 origin master
|
||||
;;
|
||||
esac
|
||||
git show "FETCH_HEAD:tools/performance/journey-baselines.json" > /tmp/base-journey-baselines.json 2>/dev/null ||
|
||||
rm -f /tmp/base-journey-baselines.json
|
||||
node tools/performance/check-baseline-direction.mjs \
|
||||
--base /tmp/base-journey-baselines.json \
|
||||
--head tools/performance/journey-baselines.json
|
||||
|
||||
# The production configuration is what users download; measuring
|
||||
# any other build would ratchet a number nobody ships.
|
||||
- name: Build web app (production)
|
||||
run: pnpm nx build web --skip-nx-cache
|
||||
env:
|
||||
CI: true
|
||||
NX_TASKS_RUNNER_DYNAMIC_OUTPUT: false
|
||||
|
||||
# Fails when renderer.initialBytes exceeds the value committed in
|
||||
# tools/performance/journey-baselines.json. Baselines only move
|
||||
# down, with the printed measurement as evidence; the contract is
|
||||
# docs/architecture/performance-journeys.md.
|
||||
- name: Check renderer.initialBytes against the baseline
|
||||
run: pnpm run perf:initial-bytes:check
|
||||
|
||||
- name: Upload journey summary
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: performance-journey-summary
|
||||
path: dist/performance/
|
||||
retention-days: 14
|
||||
|
||||
unit-and-typecheck:
|
||||
name: Unit Tests and Typechecks
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -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
|
||||
@@ -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.**
|
||||
|
||||
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 103 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 980 KiB After Width: | Height: | Size: 158 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 95 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 1.0 MiB After Width: | Height: | Size: 95 KiB |
@@ -39,6 +39,7 @@ export default {
|
||||
tslib: 'tslib/tslib.es6.js',
|
||||
'^iptv-playlist-parser$':
|
||||
'<rootDir>/src/test-stubs/iptv-playlist-parser.mjs',
|
||||
'^@package$': '<rootDir>/src/test-stubs/package.mjs',
|
||||
'^shaka-player$': '<rootDir>/src/test-stubs/shaka-player.js',
|
||||
'^video.js$': '<rootDir>/src/test-stubs/video-js.js',
|
||||
'^rxjs': '<rootDir>/../../node_modules/rxjs/dist/bundles/rxjs.umd.js',
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { formatDate } from '@angular/common';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { normalizeDateLocale } from '@iptvnator/pipes';
|
||||
import { Language } from '@iptvnator/shared/interfaces';
|
||||
import {
|
||||
APP_DATE_LOCALE_LOADERS,
|
||||
AppDateLocaleService,
|
||||
registerAppDateLocale,
|
||||
} from './app-date-locales';
|
||||
|
||||
const sampleDate = new Date(2024, 0, 15, 13, 5);
|
||||
|
||||
describe('registerAppDateLocale', () => {
|
||||
it('resolves for English without loading anything', async () => {
|
||||
const loader = jest.fn(() => import('@angular/common/locales/de'));
|
||||
|
||||
await registerAppDateLocale('en', { en: loader });
|
||||
await registerAppDateLocale('', { en: loader });
|
||||
await registerAppDateLocale(undefined, { en: loader });
|
||||
|
||||
expect(loader).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('loads the locale once and shares the in-flight import', async () => {
|
||||
const loader = jest.fn(() => import('@angular/common/locales/fr'));
|
||||
|
||||
await Promise.all([
|
||||
registerAppDateLocale('fr', { fr: loader }),
|
||||
registerAppDateLocale('fr', { fr: loader }),
|
||||
]);
|
||||
await registerAppDateLocale('fr', { fr: loader });
|
||||
|
||||
expect(loader).toHaveBeenCalledTimes(1);
|
||||
expect(formatDate(sampleDate, 'MMMM', 'fr')).toBe('janvier');
|
||||
});
|
||||
|
||||
it('maps app language aliases to Angular locale ids before loading', async () => {
|
||||
const loader = jest.fn(() => import('@angular/common/locales/be'));
|
||||
|
||||
await registerAppDateLocale('by', { be: loader });
|
||||
|
||||
expect(loader).toHaveBeenCalledTimes(1);
|
||||
expect(formatDate(sampleDate, 'MMMM', 'be')).toBe('студзеня');
|
||||
});
|
||||
|
||||
it('falls back to English formatting on a failed import and retries on the next call', async () => {
|
||||
const failing = jest.fn(() => Promise.reject(new Error('offline')));
|
||||
const working = jest.fn(() => import('@angular/common/locales/pl'));
|
||||
|
||||
await expect(
|
||||
registerAppDateLocale('pl', { pl: failing })
|
||||
).resolves.toBeUndefined();
|
||||
expect(formatDate(sampleDate, 'MMMM', 'pl')).toBe('January');
|
||||
|
||||
await registerAppDateLocale('pl', { pl: working });
|
||||
|
||||
expect(working).toHaveBeenCalledTimes(1);
|
||||
expect(formatDate(sampleDate, 'MMMM', 'pl')).toBe('stycznia');
|
||||
});
|
||||
|
||||
it('resolves for a language without bundled data instead of rejecting', async () => {
|
||||
await expect(registerAppDateLocale('xx', {})).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('bundles a loader for every supported language except English', () => {
|
||||
const expectedLocales = Object.values(Language)
|
||||
.filter((language) => language !== Language.ENGLISH)
|
||||
.map((language) => normalizeDateLocale(language))
|
||||
.sort();
|
||||
|
||||
expect(Object.keys(APP_DATE_LOCALE_LOADERS).sort()).toEqual(
|
||||
expectedLocales
|
||||
);
|
||||
});
|
||||
|
||||
it('registers working data for every supported language', async () => {
|
||||
for (const language of Object.values(Language)) {
|
||||
await registerAppDateLocale(language);
|
||||
const locale = normalizeDateLocale(language);
|
||||
expect(formatDate(sampleDate, 'MMMM', locale)).not.toBe('');
|
||||
}
|
||||
expect(formatDate(sampleDate, 'MMMM', 'ar-MA')).toBe('يناير');
|
||||
expect(formatDate(sampleDate, 'MMMM', 'zh-Hant')).toBe('1月');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AppDateLocaleService', () => {
|
||||
let translate: { use: jest.Mock };
|
||||
let service: AppDateLocaleService;
|
||||
|
||||
beforeEach(() => {
|
||||
translate = { use: jest.fn() };
|
||||
TestBed.configureTestingModule({
|
||||
providers: [{ provide: TranslateService, useValue: translate }],
|
||||
});
|
||||
service = TestBed.inject(AppDateLocaleService);
|
||||
});
|
||||
|
||||
it('delegates register to registerAppDateLocale', async () => {
|
||||
await expect(
|
||||
service.register(Language.GERMAN)
|
||||
).resolves.toBeUndefined();
|
||||
expect(formatDate(sampleDate, 'MMMM', 'de')).toBe('Januar');
|
||||
});
|
||||
|
||||
it('registers the locale data before switching the language', async () => {
|
||||
await service.use(Language.HUNGARIAN);
|
||||
|
||||
expect(translate.use).toHaveBeenCalledWith(Language.HUNGARIAN);
|
||||
expect(formatDate(sampleDate, 'MMMM', 'hu')).toBe('január');
|
||||
});
|
||||
|
||||
it('applies only the language requested last when switches overlap', async () => {
|
||||
await Promise.all([
|
||||
service.use(Language.ITALIAN),
|
||||
service.use(Language.TURKISH),
|
||||
]);
|
||||
|
||||
expect(translate.use).toHaveBeenCalledTimes(1);
|
||||
expect(translate.use).toHaveBeenCalledWith(Language.TURKISH);
|
||||
});
|
||||
});
|
||||
@@ -1,48 +1,123 @@
|
||||
import { registerLocaleData } from '@angular/common';
|
||||
import localeAr from '@angular/common/locales/ar';
|
||||
import localeArMa from '@angular/common/locales/ar-MA';
|
||||
import localeBe from '@angular/common/locales/be';
|
||||
import localeDe from '@angular/common/locales/de';
|
||||
import localeEl from '@angular/common/locales/el';
|
||||
import localeEs from '@angular/common/locales/es';
|
||||
import localeFr from '@angular/common/locales/fr';
|
||||
import localeHu from '@angular/common/locales/hu';
|
||||
import localeIt from '@angular/common/locales/it';
|
||||
import localeJa from '@angular/common/locales/ja';
|
||||
import localeKo from '@angular/common/locales/ko';
|
||||
import localeNl from '@angular/common/locales/nl';
|
||||
import localePl from '@angular/common/locales/pl';
|
||||
import localePt from '@angular/common/locales/pt';
|
||||
import localeRu from '@angular/common/locales/ru';
|
||||
import localeTr from '@angular/common/locales/tr';
|
||||
import localeZh from '@angular/common/locales/zh';
|
||||
import localeZhHant from '@angular/common/locales/zh-Hant';
|
||||
import localeEn from '@angular/common/locales/en';
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { TranslateService } from '@ngx-translate/core';
|
||||
import { normalizeDateLocale } from '@iptvnator/pipes';
|
||||
import { createDevLogger } from '@iptvnator/shared/interfaces';
|
||||
|
||||
let localesRegistered = false;
|
||||
type LocaleDataModule = { default: unknown };
|
||||
export type AppDateLocaleLoader = () => Promise<LocaleDataModule>;
|
||||
|
||||
export function registerAppDateLocales(): void {
|
||||
if (localesRegistered) {
|
||||
return;
|
||||
const debug = createDevLogger('AppDateLocales');
|
||||
|
||||
/**
|
||||
* Angular ships only English locale data; every other UI language needs its
|
||||
* data registered before `DatePipe` can format with it. Each entry is a
|
||||
* separate lazy chunk, keyed by the Angular locale id that
|
||||
* `normalizeDateLocale()` produces for an app language (`by` -> `be`,
|
||||
* `ary` -> `ar-MA`, `zhtw` -> `zh-Hant`). Importing all of them eagerly put
|
||||
* the data for 18 languages into the initial bundle of every user.
|
||||
*/
|
||||
export const APP_DATE_LOCALE_LOADERS: Readonly<
|
||||
Record<string, AppDateLocaleLoader>
|
||||
> = {
|
||||
ar: () => import('@angular/common/locales/ar'),
|
||||
'ar-MA': () => import('@angular/common/locales/ar-MA'),
|
||||
be: () => import('@angular/common/locales/be'),
|
||||
de: () => import('@angular/common/locales/de'),
|
||||
el: () => import('@angular/common/locales/el'),
|
||||
es: () => import('@angular/common/locales/es'),
|
||||
fr: () => import('@angular/common/locales/fr'),
|
||||
hu: () => import('@angular/common/locales/hu'),
|
||||
it: () => import('@angular/common/locales/it'),
|
||||
ja: () => import('@angular/common/locales/ja'),
|
||||
ko: () => import('@angular/common/locales/ko'),
|
||||
nl: () => import('@angular/common/locales/nl'),
|
||||
pl: () => import('@angular/common/locales/pl'),
|
||||
pt: () => import('@angular/common/locales/pt'),
|
||||
ru: () => import('@angular/common/locales/ru'),
|
||||
tr: () => import('@angular/common/locales/tr'),
|
||||
zh: () => import('@angular/common/locales/zh'),
|
||||
'zh-Hant': () => import('@angular/common/locales/zh-Hant'),
|
||||
};
|
||||
|
||||
const registeredLocales = new Set<string>(['en']);
|
||||
const pendingLocales = new Map<string, Promise<void>>();
|
||||
|
||||
/**
|
||||
* Registers the Angular locale data for an app language, loading it on first
|
||||
* use. Resolves once `DatePipe` can format with that locale, so call it before
|
||||
* `TranslateService.use()`: the language switch re-renders every date with the
|
||||
* new locale, and a locale whose data has not arrived throws. English resolves
|
||||
* immediately, as does a locale that is already registered or in flight.
|
||||
*
|
||||
* A failed load resolves rather than rejects, but never leaves the locale
|
||||
* without data: English formatting is registered under the requested id so
|
||||
* `DatePipe` keeps rendering instead of throwing in every template that passes
|
||||
* the locale. The locale is not marked registered, so the next call retries
|
||||
* the import and a success replaces the fallback.
|
||||
*/
|
||||
export function registerAppDateLocale(
|
||||
language: string | null | undefined,
|
||||
loaders: Readonly<
|
||||
Record<string, AppDateLocaleLoader>
|
||||
> = APP_DATE_LOCALE_LOADERS
|
||||
): Promise<void> {
|
||||
const locale = normalizeDateLocale(language);
|
||||
if (registeredLocales.has(locale)) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
const inFlight = pendingLocales.get(locale);
|
||||
if (inFlight) {
|
||||
return inFlight;
|
||||
}
|
||||
const loader = loaders[locale];
|
||||
if (!loader) {
|
||||
debug('No bundled Angular locale data for', locale);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
registerLocaleData(localeAr, 'ar');
|
||||
registerLocaleData(localeArMa, 'ar-MA');
|
||||
registerLocaleData(localeBe, 'be');
|
||||
registerLocaleData(localeDe, 'de');
|
||||
registerLocaleData(localeEl, 'el');
|
||||
registerLocaleData(localeEs, 'es');
|
||||
registerLocaleData(localeFr, 'fr');
|
||||
registerLocaleData(localeHu, 'hu');
|
||||
registerLocaleData(localeIt, 'it');
|
||||
registerLocaleData(localeJa, 'ja');
|
||||
registerLocaleData(localeKo, 'ko');
|
||||
registerLocaleData(localeNl, 'nl');
|
||||
registerLocaleData(localePl, 'pl');
|
||||
registerLocaleData(localePt, 'pt');
|
||||
registerLocaleData(localeRu, 'ru');
|
||||
registerLocaleData(localeTr, 'tr');
|
||||
registerLocaleData(localeZh, 'zh');
|
||||
registerLocaleData(localeZhHant, 'zh-Hant');
|
||||
|
||||
localesRegistered = true;
|
||||
const load = loader()
|
||||
.then((module) => {
|
||||
registerLocaleData(module.default, locale);
|
||||
registeredLocales.add(locale);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
debug(
|
||||
'Loading Angular locale data failed; using English formatting for',
|
||||
locale,
|
||||
error
|
||||
);
|
||||
registerLocaleData(localeEn, locale);
|
||||
})
|
||||
.finally(() => {
|
||||
pendingLocales.delete(locale);
|
||||
});
|
||||
pendingLocales.set(locale, load);
|
||||
return load;
|
||||
}
|
||||
|
||||
/** Gates UI language switches on the locale data they render with. */
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AppDateLocaleService {
|
||||
private readonly translate = inject(TranslateService);
|
||||
private latestRequest = 0;
|
||||
|
||||
register(language: string | null | undefined): Promise<void> {
|
||||
return registerAppDateLocale(language);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the locale data, then switches the UI language. Switches are
|
||||
* ordered by request, not by completion: a switch whose data arrives
|
||||
* after a newer request was made is dropped, so the language chosen last
|
||||
* is the one that ends up active.
|
||||
*/
|
||||
async use(language: string): Promise<void> {
|
||||
const request = ++this.latestRequest;
|
||||
await registerAppDateLocale(language);
|
||||
if (request === this.latestRequest) {
|
||||
this.translate.use(language);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
// `ng build --env=prod` then `index.prod.ts` will be used instead.
|
||||
// The list of which env maps to which file can be found in `.angular-cli.json`.
|
||||
|
||||
import packageJson from '@package';
|
||||
import { version as appVersion } from '@package';
|
||||
|
||||
export const AppConfig = {
|
||||
production: false,
|
||||
environment: 'DEV',
|
||||
version: packageJson.version,
|
||||
version: appVersion,
|
||||
BACKEND_URL: 'http://localhost:3000',
|
||||
};
|
||||
@@ -1,8 +1,8 @@
|
||||
import packageJson from '@package';
|
||||
import { version as appVersion } from '@package';
|
||||
|
||||
export const AppConfig = {
|
||||
production: true,
|
||||
environment: 'PROD',
|
||||
version: packageJson.version,
|
||||
version: appVersion,
|
||||
BACKEND_URL: 'https://iptvnator-playlist-parser-api.vercel.app',
|
||||
};
|
||||
@@ -1,8 +1,8 @@
|
||||
import packageJson from '@package';
|
||||
import { version as appVersion } from '@package';
|
||||
|
||||
export const AppConfig = {
|
||||
production: false,
|
||||
environment: 'LOCAL',
|
||||
version: packageJson.version,
|
||||
version: appVersion,
|
||||
BACKEND_URL: 'http://localhost:3000',
|
||||
};
|
||||
@@ -1,8 +1,8 @@
|
||||
import packageJson from '@package';
|
||||
import { version as appVersion } from '@package';
|
||||
|
||||
export const AppConfig = {
|
||||
production: false,
|
||||
environment: 'WEB',
|
||||
version: packageJson.version,
|
||||
version: appVersion,
|
||||
BACKEND_URL: 'http://localhost:3333',
|
||||
};
|
||||
@@ -1,10 +1,8 @@
|
||||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { resolveRestoredRendererRoute } from '@iptvnator/shared/interfaces';
|
||||
import { registerAppDateLocales } from './app/app-date-locales';
|
||||
import { registerAppDateLocale } from './app/app-date-locales';
|
||||
import { AppComponent } from './app/app.component';
|
||||
import { appConfig } from './app/app.config';
|
||||
|
||||
registerAppDateLocales();
|
||||
import { appConfig, getInitialLanguage } from './app/app.config';
|
||||
|
||||
// A reloaded packaged renderer arrives on index.html with the route it was
|
||||
// on carried in the query string (the Electron main process recovers the
|
||||
@@ -18,7 +16,12 @@ if (restoredHref !== null) {
|
||||
window.history.replaceState(window.history.state, '', restoredHref);
|
||||
}
|
||||
|
||||
bootstrapApplication(AppComponent, appConfig)
|
||||
// Angular ships only English locale data; the preferred language's data is a
|
||||
// lazy chunk. It is awaited before bootstrapping because the first route
|
||||
// renders dates with that locale, and a locale without data throws. English
|
||||
// (the default) resolves immediately.
|
||||
registerAppDateLocale(getInitialLanguage())
|
||||
.then(() => bootstrapApplication(AppComponent, appConfig))
|
||||
.then(() => {
|
||||
// Splash is rendered eagerly by index.html so the user sees something
|
||||
// immediately instead of a blank Material-grey background. Once Angular
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
// Jest's ESM loader exposes a JSON module only as a default export, while the
|
||||
// app imports `{ version }` from '@package' so esbuild can tree-shake the rest
|
||||
// of package.json out of the bundle. This stub serves the real file's fields
|
||||
// as named exports for tests.
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
const packageJson = JSON.parse(
|
||||
readFileSync(new URL('../../../../package.json', import.meta.url), 'utf8')
|
||||
);
|
||||
|
||||
export const version = packageJson.version;
|
||||
export default packageJson;
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
- Date display should follow the user-selected app language from `TranslateService`.
|
||||
- When a template renders localized month or weekday names, pass the normalized app locale explicitly to `DatePipe`.
|
||||
- Angular locale data is registered in [apps/web/src/app/app-date-locales.ts](/apps/web/src/app/app-date-locales.ts).
|
||||
- Angular locale data is loaded lazily per language by `registerAppDateLocale()` in [apps/web/src/app/app-date-locales.ts](/apps/web/src/app/app-date-locales.ts): `main.ts` awaits the initial language's data before bootstrapping, and every `TranslateService.use()` call site registers the data first (through `AppDateLocaleService`), because the switch re-renders dates with the new locale and a locale without data throws. English needs no data. `AppDateLocaleService.use()` applies only the language requested last when switches overlap, and a locale whose chunk fails to load falls back to English formatting under that locale id until a later call loads it.
|
||||
- App language aliases are normalized in [libs/ui/pipes/src/lib/date-format.util.ts](/libs/ui/pipes/src/lib/date-format.util.ts):
|
||||
- `ary` -> `ar-MA`
|
||||
- `by` -> `be`
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) |
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ export default {
|
||||
tslib: 'tslib/tslib.es6.js',
|
||||
'^iptv-playlist-parser$':
|
||||
'<rootDir>/apps/web/src/test-stubs/iptv-playlist-parser.mjs',
|
||||
'^@package$': '<rootDir>/apps/web/src/test-stubs/package.mjs',
|
||||
'^shaka-player$': '<rootDir>/apps/web/src/test-stubs/shaka-player.js',
|
||||
'^rxjs': '<rootDir>/node_modules/rxjs/dist/bundles/rxjs.umd.js',
|
||||
'^uuid$': '<rootDir>/node_modules/uuid/wrapper.mjs',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { DOCUMENT } from '@angular/common';
|
||||
import packageJson from '@package';
|
||||
import { version as appVersion } from '@package';
|
||||
import { createDiagnosticReport } from './playback-diagnostic-report.util';
|
||||
import { ClipboardModule } from '@angular/cdk/clipboard';
|
||||
import {
|
||||
@@ -91,7 +91,7 @@ export class PlaybackDiagnosticPanelComponent {
|
||||
readonly diagnosticReport = computed(() =>
|
||||
createDiagnosticReport(
|
||||
this.diagnostic(),
|
||||
packageJson.version,
|
||||
appVersion,
|
||||
this.document.defaultView?.navigator.userAgent ?? ''
|
||||
)
|
||||
);
|
||||
|
||||
@@ -71,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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* Compares a journey summary (written by the measurement scripts) against the
|
||||
* committed baselines in tools/performance/journey-baselines.json.
|
||||
*
|
||||
* Rules, from docs/architecture/performance-journeys.md:
|
||||
* - A counter above its baseline fails. Counters are exact; there is no slack.
|
||||
* - An entry with `toleranceRatio` (wall-clock) fails above
|
||||
* `value * toleranceRatio`.
|
||||
* - A baseline without a measurement fails, so removing a measurement can
|
||||
* never silently disable the ratchet.
|
||||
* - A measurement below its baseline prints a "can tighten" hint. Baselines
|
||||
* are lowered by hand, with the measured output as evidence, never raised.
|
||||
* - Checking nothing is a failure: an empty baselines file, or `--only`
|
||||
* naming an entry that does not exist, must not exit 0.
|
||||
*
|
||||
* `--only <journey>/<counter>` (repeatable) restricts the check to the named
|
||||
* baselines, so a script that measures one counter can check that counter
|
||||
* without every other baseline failing as unmeasured.
|
||||
*
|
||||
* Usage:
|
||||
* node tools/performance/check-journey-ratchet.mjs --summary <summary.json>
|
||||
* [--baselines tools/performance/journey-baselines.json]
|
||||
* [--only launch/renderer.initialBytes ...]
|
||||
*/
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export const DEFAULT_BASELINES_PATH =
|
||||
'tools/performance/journey-baselines.json';
|
||||
|
||||
function formatNumber(value) {
|
||||
return Number.isInteger(value)
|
||||
? value.toLocaleString('en-US')
|
||||
: value.toLocaleString('en-US', { maximumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
function isPlainObject(value) {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function validateBaselines(baselines) {
|
||||
if (!isPlainObject(baselines) || !isPlainObject(baselines.journeys)) {
|
||||
throw new Error(
|
||||
'Baselines file must be an object with a "journeys" map.'
|
||||
);
|
||||
}
|
||||
for (const [journey, entries] of Object.entries(baselines.journeys)) {
|
||||
if (!isPlainObject(entries)) {
|
||||
throw new Error(
|
||||
`Baseline journey "${journey}" must map counter names to entries.`
|
||||
);
|
||||
}
|
||||
for (const [name, entry] of Object.entries(entries)) {
|
||||
const label = `${journey}/${name}`;
|
||||
if (!isPlainObject(entry) || !Number.isFinite(entry.value)) {
|
||||
throw new Error(
|
||||
`Baseline ${label} needs a finite numeric "value".`
|
||||
);
|
||||
}
|
||||
if (
|
||||
entry.toleranceRatio !== undefined &&
|
||||
!(
|
||||
Number.isFinite(entry.toleranceRatio) &&
|
||||
entry.toleranceRatio >= 1
|
||||
)
|
||||
) {
|
||||
throw new Error(
|
||||
`Baseline ${label} has an invalid "toleranceRatio" (must be >= 1).`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return baselines;
|
||||
}
|
||||
|
||||
/**
|
||||
* A baseline entry is either a counter (exact) or a wall-clock entry (carries
|
||||
* `toleranceRatio`), and each is read only from its own summary section. A
|
||||
* value that moved to the other section is treated as missing, so a summary
|
||||
* schema regression fails the ratchet instead of slipping through it.
|
||||
*/
|
||||
function summarySection(entry) {
|
||||
return entry.toleranceRatio !== undefined ? 'wallClock' : 'counters';
|
||||
}
|
||||
|
||||
function measuredValue(summaryJourney, name, entry) {
|
||||
if (!isPlainObject(summaryJourney)) return undefined;
|
||||
return summaryJourney[summarySection(entry)]?.[name];
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure comparison. Returns every outcome so the CLI and tests can render it;
|
||||
* `failures` non-empty means the ratchet is broken.
|
||||
*/
|
||||
export function compareToBaselines({ baselines, summary, only = [] }) {
|
||||
validateBaselines(baselines);
|
||||
const summaryJourneys = isPlainObject(summary?.journeys)
|
||||
? summary.journeys
|
||||
: {};
|
||||
const result = {
|
||||
failures: [],
|
||||
tightenable: [],
|
||||
passed: [],
|
||||
unbaselined: [],
|
||||
};
|
||||
const selected = new Set(only);
|
||||
let evaluated = 0;
|
||||
|
||||
for (const target of only) {
|
||||
const [journey, ...rest] = target.split('/');
|
||||
if (baselines.journeys[journey]?.[rest.join('/')] === undefined) {
|
||||
result.failures.push(
|
||||
`${target}: --only names a baseline that does not exist in the baselines file.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [journey, entries] of Object.entries(baselines.journeys)) {
|
||||
for (const [name, entry] of Object.entries(entries)) {
|
||||
const label = `${journey}/${name}`;
|
||||
if (selected.size > 0 && !selected.has(label)) continue;
|
||||
evaluated += 1;
|
||||
const unit = entry.unit ? ` ${entry.unit}` : '';
|
||||
const measured = measuredValue(
|
||||
summaryJourneys[journey],
|
||||
name,
|
||||
entry
|
||||
);
|
||||
|
||||
if (measured === undefined) {
|
||||
result.failures.push(
|
||||
`${label}: baseline ${formatNumber(entry.value)}${unit} has no measurement under journeys.${journey}.${summarySection(entry)} in the summary. The ratchet cannot be bypassed by dropping a measurement; restore it.`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (typeof measured !== 'number' || !Number.isFinite(measured)) {
|
||||
result.failures.push(
|
||||
`${label}: measured value ${JSON.stringify(measured)} is not a finite number.`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const limit =
|
||||
entry.toleranceRatio !== undefined
|
||||
? entry.value * entry.toleranceRatio
|
||||
: entry.value;
|
||||
const limitText =
|
||||
entry.toleranceRatio !== undefined
|
||||
? `${formatNumber(limit)} (baseline ${formatNumber(entry.value)} × ${entry.toleranceRatio})`
|
||||
: `baseline ${formatNumber(entry.value)}`;
|
||||
|
||||
if (measured > limit) {
|
||||
result.failures.push(
|
||||
`${label}: ${formatNumber(measured)}${unit} exceeds ${limitText} by ${formatNumber(measured - limit)}${unit}. Bring the value back down; baselines only move down. If the growth is a deliberate trade-off, say so in the PR and let the maintainer decide.`
|
||||
);
|
||||
} else if (measured < entry.value) {
|
||||
result.tightenable.push(
|
||||
`${label}: ${formatNumber(measured)}${unit} is below baseline ${formatNumber(entry.value)} by ${formatNumber(entry.value - measured)}${unit}. Lower the baseline in ${DEFAULT_BASELINES_PATH} with this run as evidence.`
|
||||
);
|
||||
} else {
|
||||
result.passed.push(
|
||||
`${label}: ${formatNumber(measured)}${unit} within ${limitText}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const [journey, summaryJourney] of Object.entries(summaryJourneys)) {
|
||||
const measuredNames = [
|
||||
...Object.keys(summaryJourney?.counters ?? {}),
|
||||
...Object.keys(summaryJourney?.wallClock ?? {}),
|
||||
];
|
||||
for (const name of measuredNames) {
|
||||
if (baselines.journeys[journey]?.[name] === undefined) {
|
||||
result.unbaselined.push(
|
||||
`${journey}/${name}: measured but has no baseline yet. Add one to ${DEFAULT_BASELINES_PATH} once the counter is validated.`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (evaluated === 0) {
|
||||
result.failures.push(
|
||||
`No baselines were checked. ${DEFAULT_BASELINES_PATH} must keep at least one entry; a ratchet that guards nothing must not pass.`
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function formatResult(result) {
|
||||
const lines = [];
|
||||
for (const line of result.passed) lines.push(`ok ${line}`);
|
||||
for (const line of result.tightenable) lines.push(`tighten ${line}`);
|
||||
for (const line of result.unbaselined) lines.push(`note ${line}`);
|
||||
for (const line of result.failures) lines.push(`FAIL ${line}`);
|
||||
const checked =
|
||||
result.passed.length +
|
||||
result.tightenable.length +
|
||||
result.failures.length;
|
||||
lines.push(
|
||||
result.failures.length > 0
|
||||
? `Journey ratchet failed: ${result.failures.length} of ${checked} baselines exceeded.`
|
||||
: `Journey ratchet OK: ${checked} baselines checked, ${result.tightenable.length} can be tightened.`
|
||||
);
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function parseArgs(argv) {
|
||||
const options = {
|
||||
summary: null,
|
||||
baselines: DEFAULT_BASELINES_PATH,
|
||||
only: [],
|
||||
};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const argument = argv[index];
|
||||
if (argument === '--') continue;
|
||||
if (argument === '--summary') {
|
||||
options.summary = argv[++index];
|
||||
} else if (argument.startsWith('--summary=')) {
|
||||
options.summary = argument.slice('--summary='.length);
|
||||
} else if (argument === '--baselines') {
|
||||
options.baselines = argv[++index];
|
||||
} else if (argument.startsWith('--baselines=')) {
|
||||
options.baselines = argument.slice('--baselines='.length);
|
||||
} else if (argument === '--only') {
|
||||
options.only.push(argv[++index]);
|
||||
} else if (argument.startsWith('--only=')) {
|
||||
options.only.push(argument.slice('--only='.length));
|
||||
} else {
|
||||
throw new Error(`Unknown argument: ${argument}`);
|
||||
}
|
||||
if (
|
||||
options.summary === undefined ||
|
||||
options.baselines === undefined ||
|
||||
options.only.includes(undefined)
|
||||
) {
|
||||
throw new Error(`Missing value for ${argument}`);
|
||||
}
|
||||
}
|
||||
for (const target of options.only) {
|
||||
if (!/^[^/]+\/.+$/.test(target)) {
|
||||
throw new Error(
|
||||
`--only expects <journey>/<counter>, received "${target}".`
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!options.summary) {
|
||||
throw new Error('--summary <journey-summary.json> is required.');
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
async function readJson(filePath, description) {
|
||||
try {
|
||||
return JSON.parse(await readFile(filePath, 'utf8'));
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Cannot read ${description} at ${filePath}: ${error.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const isMain =
|
||||
process.argv[1] &&
|
||||
path.resolve(process.argv[1]) ===
|
||||
path.resolve(fileURLToPath(import.meta.url));
|
||||
|
||||
if (isMain) {
|
||||
try {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const baselines = await readJson(
|
||||
path.resolve(options.baselines),
|
||||
'baselines'
|
||||
);
|
||||
const summary = await readJson(
|
||||
path.resolve(options.summary),
|
||||
'journey summary'
|
||||
);
|
||||
const result = compareToBaselines({
|
||||
baselines,
|
||||
summary,
|
||||
only: options.only,
|
||||
});
|
||||
const output = formatResult(result);
|
||||
if (result.failures.length > 0) {
|
||||
console.error(output);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log(output);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`check-journey-ratchet: ${error.message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { after, before, test } from 'node:test';
|
||||
|
||||
import {
|
||||
DEFAULT_BASELINES_PATH,
|
||||
compareToBaselines,
|
||||
formatResult,
|
||||
parseArgs,
|
||||
validateBaselines,
|
||||
} from './check-journey-ratchet.mjs';
|
||||
|
||||
const scriptPath = fileURLToPath(
|
||||
new URL('./check-journey-ratchet.mjs', import.meta.url)
|
||||
);
|
||||
const committedBaselinesPath = fileURLToPath(
|
||||
new URL('./journey-baselines.json', import.meta.url)
|
||||
);
|
||||
|
||||
const baselines = {
|
||||
version: 1,
|
||||
journeys: {
|
||||
launch: {
|
||||
'renderer.initialBytes': { value: 2750491, unit: 'bytes' },
|
||||
spawnToFirstCardMs: {
|
||||
value: 1000,
|
||||
unit: 'ms',
|
||||
toleranceRatio: 1.25,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const summaryWith = (initialBytes, wallClockMs = 1000) => ({
|
||||
journeys: {
|
||||
launch: {
|
||||
counters: { 'renderer.initialBytes': initialBytes },
|
||||
wallClock: { spawnToFirstCardMs: wallClockMs },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let workDir;
|
||||
|
||||
before(async () => {
|
||||
workDir = await mkdtemp(path.join(os.tmpdir(), 'check-journey-ratchet-'));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await rm(workDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('a counter equal to its baseline passes', () => {
|
||||
const result = compareToBaselines({
|
||||
baselines,
|
||||
summary: summaryWith(2750491),
|
||||
});
|
||||
assert.deepEqual(result.failures, []);
|
||||
assert.deepEqual(result.tightenable, []);
|
||||
assert.equal(result.passed.length, 2);
|
||||
assert.match(
|
||||
result.passed[0],
|
||||
/launch\/renderer\.initialBytes: 2,750,491 bytes within baseline 2,750,491/
|
||||
);
|
||||
});
|
||||
|
||||
test('a counter one byte above its baseline fails with the delta', () => {
|
||||
const result = compareToBaselines({
|
||||
baselines,
|
||||
summary: summaryWith(2750492),
|
||||
});
|
||||
assert.equal(result.failures.length, 1);
|
||||
assert.match(
|
||||
result.failures[0],
|
||||
/launch\/renderer\.initialBytes: 2,750,492 bytes exceeds baseline 2,750,491 by 1 bytes/
|
||||
);
|
||||
assert.match(result.failures[0], /baselines only move down/);
|
||||
});
|
||||
|
||||
test('a counter below its baseline passes and asks to tighten', () => {
|
||||
const result = compareToBaselines({
|
||||
baselines,
|
||||
summary: summaryWith(2600000),
|
||||
});
|
||||
assert.deepEqual(result.failures, []);
|
||||
assert.equal(result.tightenable.length, 1);
|
||||
assert.match(
|
||||
result.tightenable[0],
|
||||
/below baseline 2,750,491 by 150,491 bytes/
|
||||
);
|
||||
assert.match(
|
||||
result.tightenable[0],
|
||||
new RegExp(DEFAULT_BASELINES_PATH.replace(/\//g, '\\/'))
|
||||
);
|
||||
});
|
||||
|
||||
test('wall-clock entries fail only above value × toleranceRatio', () => {
|
||||
const within = compareToBaselines({
|
||||
baselines,
|
||||
summary: summaryWith(2750491, 1250),
|
||||
});
|
||||
assert.deepEqual(within.failures, []);
|
||||
assert.match(
|
||||
within.passed[1],
|
||||
/1,250 ms within 1,250 \(baseline 1,000 × 1\.25\)/
|
||||
);
|
||||
|
||||
const above = compareToBaselines({
|
||||
baselines,
|
||||
summary: summaryWith(2750491, 1251),
|
||||
});
|
||||
assert.equal(above.failures.length, 1);
|
||||
assert.match(
|
||||
above.failures[0],
|
||||
/spawnToFirstCardMs: 1,251 ms exceeds 1,250 \(baseline 1,000 × 1\.25\) by 1 ms/
|
||||
);
|
||||
|
||||
const below = compareToBaselines({
|
||||
baselines,
|
||||
summary: summaryWith(2750491, 900),
|
||||
});
|
||||
assert.equal(below.tightenable.length, 1);
|
||||
assert.match(
|
||||
below.tightenable[0],
|
||||
/spawnToFirstCardMs: 900 ms is below baseline 1,000/
|
||||
);
|
||||
});
|
||||
|
||||
test('a baseline without a measurement fails instead of being skipped', () => {
|
||||
const result = compareToBaselines({
|
||||
baselines,
|
||||
summary: { journeys: { launch: { counters: {} } } },
|
||||
});
|
||||
assert.equal(result.failures.length, 2);
|
||||
assert.match(
|
||||
result.failures[0],
|
||||
/renderer\.initialBytes: baseline 2,750,491 bytes has no measurement/
|
||||
);
|
||||
assert.match(
|
||||
result.failures[0],
|
||||
/cannot be bypassed by dropping a measurement/
|
||||
);
|
||||
});
|
||||
|
||||
test('a value in the wrong summary section counts as missing', () => {
|
||||
const counterInWallClock = compareToBaselines({
|
||||
baselines,
|
||||
summary: {
|
||||
journeys: {
|
||||
launch: {
|
||||
counters: {},
|
||||
wallClock: {
|
||||
'renderer.initialBytes': 2750491,
|
||||
spawnToFirstCardMs: 1000,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(counterInWallClock.failures.length, 1);
|
||||
assert.match(
|
||||
counterInWallClock.failures[0],
|
||||
/renderer\.initialBytes: baseline 2,750,491 bytes has no measurement under journeys\.launch\.counters/
|
||||
);
|
||||
|
||||
const wallClockInCounters = compareToBaselines({
|
||||
baselines,
|
||||
summary: {
|
||||
journeys: {
|
||||
launch: {
|
||||
counters: {
|
||||
'renderer.initialBytes': 2750491,
|
||||
spawnToFirstCardMs: 1000,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(wallClockInCounters.failures.length, 1);
|
||||
assert.match(
|
||||
wallClockInCounters.failures[0],
|
||||
/spawnToFirstCardMs: baseline 1,000 ms has no measurement under journeys\.launch\.wallClock/
|
||||
);
|
||||
});
|
||||
|
||||
test('an empty or malformed summary fails every baseline', () => {
|
||||
assert.equal(
|
||||
compareToBaselines({ baselines, summary: {} }).failures.length,
|
||||
2
|
||||
);
|
||||
assert.equal(
|
||||
compareToBaselines({ baselines, summary: null }).failures.length,
|
||||
2
|
||||
);
|
||||
const nonNumeric = compareToBaselines({
|
||||
baselines,
|
||||
summary: {
|
||||
journeys: {
|
||||
launch: { counters: { 'renderer.initialBytes': '2750491' } },
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.match(nonNumeric.failures[0], /"2750491" is not a finite number/);
|
||||
});
|
||||
|
||||
test('a measured counter without a baseline is reported but does not fail', () => {
|
||||
const summary = summaryWith(2750491);
|
||||
summary.journeys.launch.counters['renderer.cdTicksToFirstCard'] = 12;
|
||||
summary.journeys.search = { counters: { sqlStatementsPerKeystroke: 3 } };
|
||||
const result = compareToBaselines({ baselines, summary });
|
||||
assert.deepEqual(result.failures, []);
|
||||
assert.deepEqual(
|
||||
result.unbaselined.map((line) => line.split(':')[0]),
|
||||
[
|
||||
'launch/renderer.cdTicksToFirstCard',
|
||||
'search/sqlStatementsPerKeystroke',
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
test('an empty baselines file fails instead of passing vacuously', () => {
|
||||
const result = compareToBaselines({
|
||||
baselines: { journeys: {} },
|
||||
summary: summaryWith(2750491),
|
||||
});
|
||||
assert.equal(result.failures.length, 1);
|
||||
assert.match(result.failures[0], /No baselines were checked/);
|
||||
assert.match(
|
||||
formatResult(result),
|
||||
/Journey ratchet failed: 1 of 1 baselines exceeded/
|
||||
);
|
||||
});
|
||||
|
||||
test('--only restricts the check to the named baselines', () => {
|
||||
const result = compareToBaselines({
|
||||
baselines,
|
||||
summary: {
|
||||
journeys: {
|
||||
launch: { counters: { 'renderer.initialBytes': 2750491 } },
|
||||
},
|
||||
},
|
||||
only: ['launch/renderer.initialBytes'],
|
||||
});
|
||||
assert.deepEqual(result.failures, []);
|
||||
assert.equal(result.passed.length, 1);
|
||||
assert.match(result.passed[0], /launch\/renderer\.initialBytes/);
|
||||
});
|
||||
|
||||
test('--only naming a missing baseline fails rather than checking nothing', () => {
|
||||
const result = compareToBaselines({
|
||||
baselines,
|
||||
summary: summaryWith(2750491),
|
||||
only: ['launch/does.notExist'],
|
||||
});
|
||||
assert.equal(result.failures.length, 2);
|
||||
assert.match(
|
||||
result.failures[0],
|
||||
/launch\/does\.notExist: --only names a baseline that does not exist/
|
||||
);
|
||||
assert.match(result.failures[1], /No baselines were checked/);
|
||||
});
|
||||
|
||||
test('rejects malformed baseline files', () => {
|
||||
assert.throws(() => validateBaselines({}), /object with a "journeys" map/);
|
||||
assert.throws(
|
||||
() => validateBaselines({ journeys: { launch: [] } }),
|
||||
/journey "launch" must map/
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
validateBaselines({
|
||||
journeys: { launch: { x: { value: 'big' } } },
|
||||
}),
|
||||
/Baseline launch\/x needs a finite numeric "value"/
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
validateBaselines({
|
||||
journeys: { launch: { x: { value: 1, toleranceRatio: 0.5 } } },
|
||||
}),
|
||||
/invalid "toleranceRatio"/
|
||||
);
|
||||
});
|
||||
|
||||
test('formats a summary line for both outcomes', () => {
|
||||
const ok = formatResult(
|
||||
compareToBaselines({ baselines, summary: summaryWith(2600000) })
|
||||
);
|
||||
assert.match(ok, /^ok {7}launch\/spawnToFirstCardMs/m);
|
||||
assert.match(ok, /^tighten {2}launch\/renderer\.initialBytes/m);
|
||||
assert.match(
|
||||
ok,
|
||||
/Journey ratchet OK: 2 baselines checked, 1 can be tightened\.$/
|
||||
);
|
||||
|
||||
const failed = formatResult(
|
||||
compareToBaselines({ baselines, summary: summaryWith(3000000) })
|
||||
);
|
||||
assert.match(failed, /^FAIL {5}launch\/renderer\.initialBytes/m);
|
||||
assert.match(
|
||||
failed,
|
||||
/Journey ratchet failed: 1 of 2 baselines exceeded\.$/
|
||||
);
|
||||
});
|
||||
|
||||
test('parses CLI arguments and requires --summary', () => {
|
||||
assert.deepEqual(parseArgs(['--summary', 's.json']), {
|
||||
summary: 's.json',
|
||||
baselines: DEFAULT_BASELINES_PATH,
|
||||
only: [],
|
||||
});
|
||||
assert.deepEqual(
|
||||
parseArgs([
|
||||
'--',
|
||||
'--summary=s.json',
|
||||
'--baselines=b.json',
|
||||
'--only',
|
||||
'launch/a',
|
||||
'--only=search/b',
|
||||
]),
|
||||
{
|
||||
summary: 's.json',
|
||||
baselines: 'b.json',
|
||||
only: ['launch/a', 'search/b'],
|
||||
}
|
||||
);
|
||||
assert.throws(
|
||||
() => parseArgs(['--summary', 's', '--only', 'launch']),
|
||||
/--only expects <journey>\/<counter>/
|
||||
);
|
||||
assert.throws(
|
||||
() => parseArgs(['--summary', 's', '--only']),
|
||||
/Missing value for --only/
|
||||
);
|
||||
assert.throws(
|
||||
() => parseArgs([]),
|
||||
/--summary <journey-summary\.json> is required/
|
||||
);
|
||||
assert.throws(
|
||||
() => parseArgs(['--summary']),
|
||||
/Missing value for --summary/
|
||||
);
|
||||
assert.throws(
|
||||
() => parseArgs(['--summary', 's', '--strict']),
|
||||
/Unknown argument: --strict/
|
||||
);
|
||||
});
|
||||
|
||||
test('the committed baselines file is valid and every entry names its evidence fields', async () => {
|
||||
const committed = JSON.parse(
|
||||
await readFile(committedBaselinesPath, 'utf8')
|
||||
);
|
||||
validateBaselines(committed);
|
||||
assert.ok(
|
||||
committed.journeys.launch?.['renderer.initialBytes'],
|
||||
'the launch/renderer.initialBytes baseline must stay committed; the CI ratchet guards it'
|
||||
);
|
||||
for (const entries of Object.values(committed.journeys)) {
|
||||
for (const entry of Object.values(entries)) {
|
||||
assert.match(entry.updatedAt, /^\d{4}-\d{2}-\d{2}$/);
|
||||
assert.ok(
|
||||
'evidencePr' in entry,
|
||||
'evidencePr must be present (null before the first PR)'
|
||||
);
|
||||
assert.equal(typeof entry.measuredWith, 'string');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
async function runCli(summary, extraBaselines = baselines) {
|
||||
const summaryPath = path.join(
|
||||
workDir,
|
||||
`summary-${Date.now()}-${Math.random()}.json`
|
||||
);
|
||||
const baselinesPath = path.join(
|
||||
workDir,
|
||||
`baselines-${Date.now()}-${Math.random()}.json`
|
||||
);
|
||||
await writeFile(summaryPath, JSON.stringify(summary));
|
||||
await writeFile(baselinesPath, JSON.stringify(extraBaselines));
|
||||
return spawnSync(
|
||||
process.execPath,
|
||||
[scriptPath, '--summary', summaryPath, '--baselines', baselinesPath],
|
||||
{ encoding: 'utf8' }
|
||||
);
|
||||
}
|
||||
|
||||
test('CLI exits 0 when within baselines and 1 when a counter grew', async () => {
|
||||
const ok = await runCli(summaryWith(2750491));
|
||||
assert.equal(ok.status, 0, ok.stderr);
|
||||
assert.match(ok.stdout, /Journey ratchet OK/);
|
||||
|
||||
const grew = await runCli(summaryWith(2750492));
|
||||
assert.equal(grew.status, 1);
|
||||
assert.match(grew.stderr, /FAIL {5}launch\/renderer\.initialBytes/);
|
||||
});
|
||||
|
||||
test('CLI exits 1 with a readable message when the summary is missing', () => {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[scriptPath, '--summary', path.join(workDir, 'nope.json')],
|
||||
{ encoding: 'utf8' }
|
||||
);
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(
|
||||
result.stderr,
|
||||
/check-journey-ratchet: Cannot read journey summary at/
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"version": 1,
|
||||
"journeys": {
|
||||
"launch": {
|
||||
"renderer.initialBytes": {
|
||||
"value": 2715087,
|
||||
"unit": "bytes",
|
||||
"updatedAt": "2026-09-26",
|
||||
"evidencePr": 1695,
|
||||
"measuredWith": "pnpm nx build web && pnpm run perf:initial-bytes"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* Measures the bytes a browser fetches before the Angular app can bootstrap:
|
||||
* `index.html` itself plus every same-origin script, stylesheet and
|
||||
* `modulepreload` chunk it references. This is the J1 ("launch to usable")
|
||||
* counter `renderer.initialBytes` from docs/architecture/performance-journeys.md.
|
||||
*
|
||||
* The number is read from the built output, not estimated from source, so it
|
||||
* is deterministic for a given build and can be ratcheted in CI.
|
||||
*
|
||||
* Usage:
|
||||
* node tools/performance/measure-initial-bytes.mjs [--dist dist/apps/web]
|
||||
* [--json] [--summary dist/performance/journey-summary.json]
|
||||
*/
|
||||
import { existsSync } from 'node:fs';
|
||||
import { mkdir, readFile, stat, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { parse } from 'parse5';
|
||||
|
||||
export const DEFAULT_DIST_DIR = 'dist/apps/web';
|
||||
export const INITIAL_BYTES_COUNTER = 'renderer.initialBytes';
|
||||
export const LAUNCH_JOURNEY = 'launch';
|
||||
|
||||
const EXTERNAL_URL = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;
|
||||
const HTML_NAMESPACE = 'http://www.w3.org/1999/xhtml';
|
||||
|
||||
/**
|
||||
* A resource only counts when the browser fetches it on the initial path from
|
||||
* the same origin. Manifest, icons and external URLs are not part of the
|
||||
* payload the ratchet guards: icons load lazily, and external hosts are
|
||||
* outside the build's control.
|
||||
*/
|
||||
function classify(tag, attributes) {
|
||||
if (tag === 'script') {
|
||||
return attributes.src ? { kind: 'script', url: attributes.src } : null;
|
||||
}
|
||||
const rel = (attributes.rel ?? '').toLowerCase().split(/\s+/);
|
||||
if (!attributes.href) return null;
|
||||
if (rel.includes('stylesheet')) {
|
||||
return { kind: 'stylesheet', url: attributes.href };
|
||||
}
|
||||
if (rel.includes('modulepreload')) {
|
||||
return { kind: 'modulepreload', url: attributes.href };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Yields the `script` and `link` elements the browser actually creates, in
|
||||
* document order, by parsing `index.html` with the same HTML5 algorithm the
|
||||
* browser uses (parse5, scripting enabled): comments, doctype and bogus
|
||||
* comments are not elements, script/style/noscript/textarea/title bodies are
|
||||
* text, `<template>` contents are inert and live in a separate fragment,
|
||||
* attribute values arrive with character references decoded, and an SVG
|
||||
* `<script>` inside inline SVG is in another namespace (it uses `href`, and
|
||||
* the HTML parser does not fetch it), while HTML inside `<foreignObject>`
|
||||
* is back in the HTML namespace.
|
||||
*/
|
||||
export function scanLiveTags(html) {
|
||||
const tags = [];
|
||||
const visit = (node) => {
|
||||
for (const child of node.childNodes ?? []) {
|
||||
if (
|
||||
child.namespaceURI === HTML_NAMESPACE &&
|
||||
(child.nodeName === 'script' || child.nodeName === 'link')
|
||||
) {
|
||||
tags.push({
|
||||
tag: child.nodeName,
|
||||
attributes: Object.fromEntries(
|
||||
child.attrs.map((attribute) => [
|
||||
attribute.name.toLowerCase(),
|
||||
attribute.value,
|
||||
])
|
||||
),
|
||||
});
|
||||
}
|
||||
visit(child);
|
||||
}
|
||||
};
|
||||
visit(parse(html, { scriptingEnabled: true }));
|
||||
return tags;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists the same-origin resources `index.html` puts on the initial path, in
|
||||
* document order. Duplicates are collapsed by request URL, not by file: the
|
||||
* browser fetches `chunk.js?v=1` and `chunk.js?v=2` separately, so both count,
|
||||
* while a fragment never reaches the server and is ignored. `path` is the
|
||||
* file on disk the URL maps to.
|
||||
*/
|
||||
export function extractInitialResources(html) {
|
||||
const seen = new Set();
|
||||
const resources = [];
|
||||
for (const { tag, attributes } of scanLiveTags(html)) {
|
||||
const resource = classify(tag, attributes);
|
||||
if (!resource || EXTERNAL_URL.test(resource.url)) continue;
|
||||
const url = resource.url.replace(/#.*$/, '').replace(/^\.?\//, '');
|
||||
const file = url.replace(/\?.*$/, '');
|
||||
if (!file || seen.has(url)) continue;
|
||||
seen.add(url);
|
||||
resources.push({ path: file, url, kind: resource.kind });
|
||||
}
|
||||
return resources;
|
||||
}
|
||||
|
||||
async function sizeOf(filePath) {
|
||||
const stats = await stat(filePath);
|
||||
return stats.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the built output and returns the per-file breakdown plus the counter
|
||||
* value. Missing files are an error rather than zero bytes: a broken reference
|
||||
* would otherwise look like a bundle-size win.
|
||||
*/
|
||||
export async function measureInitialBytes({ distDir, readSize = sizeOf }) {
|
||||
const indexPath = path.join(distDir, 'index.html');
|
||||
if (!existsSync(indexPath)) {
|
||||
throw new Error(
|
||||
`No index.html under ${distDir}. Build the web app first (pnpm nx build web).`
|
||||
);
|
||||
}
|
||||
const html = await readFile(indexPath, 'utf8');
|
||||
const indexBytes = await readSize(indexPath);
|
||||
const resources = [];
|
||||
const missing = [];
|
||||
|
||||
for (const resource of extractInitialResources(html)) {
|
||||
const absolute = path.join(distDir, resource.path);
|
||||
if (!existsSync(absolute)) {
|
||||
missing.push(resource.path);
|
||||
continue;
|
||||
}
|
||||
resources.push({ ...resource, bytes: await readSize(absolute) });
|
||||
}
|
||||
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`index.html references files that are not in ${distDir}: ${missing.join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
const totals = {
|
||||
indexHtml: indexBytes,
|
||||
script: 0,
|
||||
stylesheet: 0,
|
||||
modulepreload: 0,
|
||||
};
|
||||
for (const resource of resources) totals[resource.kind] += resource.bytes;
|
||||
const initialBytes =
|
||||
totals.indexHtml +
|
||||
totals.script +
|
||||
totals.stylesheet +
|
||||
totals.modulepreload;
|
||||
|
||||
return {
|
||||
distDir,
|
||||
indexHtml: { path: 'index.html', bytes: indexBytes },
|
||||
resources,
|
||||
totals: { ...totals, initialBytes },
|
||||
counters: { [INITIAL_BYTES_COUNTER]: initialBytes },
|
||||
};
|
||||
}
|
||||
|
||||
/** The journey summary shape consumed by check-journey-ratchet.mjs. */
|
||||
export function toJourneySummary(
|
||||
measurement,
|
||||
{ measuredAt = new Date() } = {}
|
||||
) {
|
||||
return {
|
||||
version: 1,
|
||||
measuredAt: measuredAt.toISOString(),
|
||||
journeys: {
|
||||
[LAUNCH_JOURNEY]: {
|
||||
counters: { ...measurement.counters },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function formatBytes(bytes) {
|
||||
return bytes.toLocaleString('en-US');
|
||||
}
|
||||
|
||||
export function formatReport(measurement) {
|
||||
const rows = [...measurement.resources].sort((a, b) => b.bytes - a.bytes);
|
||||
const width = Math.max(
|
||||
...rows.map((row) => row.url.length),
|
||||
'index.html'.length
|
||||
);
|
||||
const lines = [
|
||||
`Initial payload of ${measurement.distDir}`,
|
||||
'',
|
||||
`${'index.html'.padEnd(width)} html ${formatBytes(measurement.indexHtml.bytes).padStart(11)}`,
|
||||
...rows.map(
|
||||
(row) =>
|
||||
`${row.url.padEnd(width)} ${row.kind.padEnd(13)} ${formatBytes(row.bytes).padStart(11)}`
|
||||
),
|
||||
'',
|
||||
`scripts ${formatBytes(measurement.totals.script).padStart(11)}`,
|
||||
`stylesheets ${formatBytes(measurement.totals.stylesheet).padStart(11)}`,
|
||||
`modulepreload ${formatBytes(measurement.totals.modulepreload).padStart(11)}`,
|
||||
`${INITIAL_BYTES_COUNTER} = ${formatBytes(measurement.totals.initialBytes)} bytes (${measurement.resources.length} files + index.html)`,
|
||||
];
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function parseArgs(argv) {
|
||||
const options = { distDir: DEFAULT_DIST_DIR, json: false, summary: null };
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const argument = argv[index];
|
||||
if (argument === '--') continue;
|
||||
if (argument === '--json') {
|
||||
options.json = true;
|
||||
} else if (argument === '--dist') {
|
||||
options.distDir = argv[++index];
|
||||
} else if (argument.startsWith('--dist=')) {
|
||||
options.distDir = argument.slice('--dist='.length);
|
||||
} else if (argument === '--summary') {
|
||||
options.summary = argv[++index];
|
||||
} else if (argument.startsWith('--summary=')) {
|
||||
options.summary = argument.slice('--summary='.length);
|
||||
} else {
|
||||
throw new Error(`Unknown argument: ${argument}`);
|
||||
}
|
||||
if (options.distDir === undefined || options.summary === undefined) {
|
||||
throw new Error(`Missing value for ${argument}`);
|
||||
}
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
const isMain =
|
||||
process.argv[1] &&
|
||||
path.resolve(process.argv[1]) ===
|
||||
path.resolve(fileURLToPath(import.meta.url));
|
||||
|
||||
if (isMain) {
|
||||
try {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const measurement = await measureInitialBytes({
|
||||
distDir: path.resolve(options.distDir),
|
||||
});
|
||||
measurement.distDir =
|
||||
path.relative(process.cwd(), measurement.distDir) || '.';
|
||||
|
||||
if (options.summary) {
|
||||
const summaryPath = path.resolve(options.summary);
|
||||
await mkdir(path.dirname(summaryPath), { recursive: true });
|
||||
await writeFile(
|
||||
summaryPath,
|
||||
`${JSON.stringify(toJourneySummary(measurement), null, 4)}\n`
|
||||
);
|
||||
}
|
||||
|
||||
console.log(
|
||||
options.json
|
||||
? JSON.stringify(measurement, null, 4)
|
||||
: formatReport(measurement)
|
||||
);
|
||||
if (options.summary && !options.json) {
|
||||
console.log(`\nJourney summary written to ${options.summary}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`measure-initial-bytes: ${error.message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { after, before, test } from 'node:test';
|
||||
|
||||
import {
|
||||
DEFAULT_DIST_DIR,
|
||||
INITIAL_BYTES_COUNTER,
|
||||
extractInitialResources,
|
||||
formatReport,
|
||||
measureInitialBytes,
|
||||
parseArgs,
|
||||
scanLiveTags,
|
||||
toJourneySummary,
|
||||
} from './measure-initial-bytes.mjs';
|
||||
|
||||
const scriptPath = fileURLToPath(
|
||||
new URL('./measure-initial-bytes.mjs', import.meta.url)
|
||||
);
|
||||
|
||||
/** Mirrors the shape the Angular application builder emits for apps/web. */
|
||||
const BUILT_INDEX_HTML = `<!doctype html>
|
||||
<html><head>
|
||||
<meta charset="utf-8"/>
|
||||
<link rel="manifest" href="manifest.webmanifest"/>
|
||||
<link rel="apple-touch-icon" href="assets/icons/apple-touch-icon.png"/>
|
||||
<link rel="icon" type="image/x-icon" href="assets/icons/favicon.ico"/>
|
||||
<script src="assets/app-config.js" defer=""></script>
|
||||
<link rel="stylesheet" href="styles-VDU4SQ5F.css"></head>
|
||||
<body class="mat-app-background"><app-root></app-root>
|
||||
<link rel="modulepreload" href="chunk-B6uziQ1i.js"><link rel="modulepreload" href="chunk-Cn2Agfvf.js"><script src="polyfills-EBB6HFCX.js" type="module"></script><script src="main-EI6PCDGR.js" type="module"></script></body></html>`;
|
||||
|
||||
const BUILT_FILES = {
|
||||
'assets/app-config.js': 65,
|
||||
'styles-VDU4SQ5F.css': 311539,
|
||||
'chunk-B6uziQ1i.js': 1566,
|
||||
'chunk-Cn2Agfvf.js': 529624,
|
||||
'polyfills-EBB6HFCX.js': 35876,
|
||||
'main-EI6PCDGR.js': 1131437,
|
||||
};
|
||||
|
||||
let workDir;
|
||||
|
||||
async function writeDist(
|
||||
name,
|
||||
{ indexHtml = BUILT_INDEX_HTML, files = BUILT_FILES } = {}
|
||||
) {
|
||||
const distDir = path.join(workDir, name);
|
||||
await mkdir(distDir, { recursive: true });
|
||||
if (indexHtml !== null) {
|
||||
await writeFile(path.join(distDir, 'index.html'), indexHtml);
|
||||
}
|
||||
for (const [file, bytes] of Object.entries(files)) {
|
||||
const target = path.join(distDir, file);
|
||||
await mkdir(path.dirname(target), { recursive: true });
|
||||
await writeFile(target, 'x'.repeat(bytes));
|
||||
}
|
||||
return distDir;
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
workDir = await mkdtemp(path.join(os.tmpdir(), 'measure-initial-bytes-'));
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
await rm(workDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test('extracts scripts, stylesheets and modulepreload chunks in document order', () => {
|
||||
assert.deepEqual(
|
||||
extractInitialResources(BUILT_INDEX_HTML).map(({ path, kind }) => ({
|
||||
path,
|
||||
kind,
|
||||
})),
|
||||
[
|
||||
{ path: 'assets/app-config.js', kind: 'script' },
|
||||
{ path: 'styles-VDU4SQ5F.css', kind: 'stylesheet' },
|
||||
{ path: 'chunk-B6uziQ1i.js', kind: 'modulepreload' },
|
||||
{ path: 'chunk-Cn2Agfvf.js', kind: 'modulepreload' },
|
||||
{ path: 'polyfills-EBB6HFCX.js', kind: 'script' },
|
||||
{ path: 'main-EI6PCDGR.js', kind: 'script' },
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
test('ignores manifest, icon and external references', () => {
|
||||
const html = `
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<link rel="icon" href="favicon.ico">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com">
|
||||
<link rel="stylesheet" href="https://cdn.example.com/theme.css">
|
||||
<script src="//cdn.example.com/analytics.js"></script>
|
||||
<script src="data:text/javascript,1"></script>
|
||||
<script>inline()</script>
|
||||
<script src="main.js" type="module"></script>`;
|
||||
assert.deepEqual(extractInitialResources(html), [
|
||||
{ path: 'main.js', url: 'main.js', kind: 'script' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('deduplicates by request URL, ignores fragments and normalizes relative URLs', () => {
|
||||
const html = `
|
||||
<LINK REL="modulepreload" HREF='./chunk-a.js'>
|
||||
<link rel="modulepreload" href="chunk-a.js">
|
||||
<link rel="modulepreload" href="chunk-a.js?v=2">
|
||||
<link rel="modulepreload" href="/chunk-b.js#hash">
|
||||
<link rel="modulepreload" href="chunk-b.js#other">
|
||||
<script src=main.js></script>
|
||||
<script src="main.js"></script>`;
|
||||
assert.deepEqual(extractInitialResources(html), [
|
||||
{ path: 'chunk-a.js', url: 'chunk-a.js', kind: 'modulepreload' },
|
||||
{ path: 'chunk-a.js', url: 'chunk-a.js?v=2', kind: 'modulepreload' },
|
||||
{ path: 'chunk-b.js', url: 'chunk-b.js', kind: 'modulepreload' },
|
||||
{ path: 'main.js', url: 'main.js', kind: 'script' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('ignores commented-out tags and tag-like text inside inline scripts and styles', () => {
|
||||
const html = `
|
||||
<!-- <script src="old.js"></script> -->
|
||||
<!--
|
||||
<link rel="stylesheet" href="legacy.css">
|
||||
-->
|
||||
<script>const markup = '<script src="fake.js"><\\/script><link rel="modulepreload" href="fake-chunk.js">';</script>
|
||||
<style>/* <link rel="stylesheet" href="fake.css"> */ body { color: red; }</style>
|
||||
<script src="assets/app-config.js" defer></script>
|
||||
<script src="main.js" type="module"></script>`;
|
||||
assert.deepEqual(
|
||||
extractInitialResources(html).map((resource) => resource.url),
|
||||
['assets/app-config.js', 'main.js']
|
||||
);
|
||||
assert.deepEqual(
|
||||
scanLiveTags('<script>1 < 2</script><LINK rel=x>').map((t) => t.tag),
|
||||
['script', 'link']
|
||||
);
|
||||
// '<!' that is not '<!--' opens a bogus comment up to the next '>', so
|
||||
// the script after it is live, exactly as the HTML tokenizer sees it.
|
||||
assert.deepEqual(
|
||||
extractInitialResources(
|
||||
'<!doctype html><!<!-- a -->-- <script src="x.js"></script> -->'
|
||||
).map((resource) => resource.url),
|
||||
['x.js']
|
||||
);
|
||||
});
|
||||
|
||||
test('a comment opener inside a script body does not swallow later live tags', () => {
|
||||
const html = `<script>const x='<!--';</script><script src="main.js"></script><!-- real --><link rel="modulepreload" href="chunk.js">`;
|
||||
assert.deepEqual(
|
||||
extractInitialResources(html).map((resource) => resource.url),
|
||||
['main.js', 'chunk.js']
|
||||
);
|
||||
const reverse = `<!-- <script>x</script> --><style>a::before{content:'<!--'}</style><script src="live.js"></script>`;
|
||||
assert.deepEqual(
|
||||
extractInitialResources(reverse).map((resource) => resource.url),
|
||||
['live.js']
|
||||
);
|
||||
// Unterminated raw text swallows the rest, as it does in a browser.
|
||||
assert.deepEqual(
|
||||
extractInitialResources('<script>x<script src="a.js"></script'),
|
||||
[]
|
||||
);
|
||||
});
|
||||
|
||||
test('raw text ends only at an exact end tag, and noscript/title bodies are text', () => {
|
||||
const lookalike = `<script>const a='</scriptlet>', b='<script src="fake.js">';</script><script src="real.js"></script>`;
|
||||
assert.deepEqual(
|
||||
extractInitialResources(lookalike).map((resource) => resource.url),
|
||||
['real.js']
|
||||
);
|
||||
const fallback = `<noscript><script src="fallback.js"></script><link rel="stylesheet" href="noscript.css"></noscript><title><script src="t.js"></script></title><textarea><link rel="modulepreload" href="ta.js"></textarea><script src="app.js"></script>`;
|
||||
assert.deepEqual(
|
||||
extractInitialResources(fallback).map((resource) => resource.url),
|
||||
['app.js']
|
||||
);
|
||||
assert.deepEqual(
|
||||
extractInitialResources(
|
||||
'<script>x</script\t><script src="y.js"></script >'
|
||||
).map((r) => r.url),
|
||||
['y.js']
|
||||
);
|
||||
});
|
||||
|
||||
test('template contents are inert and character references are decoded', () => {
|
||||
const html = `<template><script src="fallback.js"></script><link rel="stylesheet" href="t.css"></template><script src="chunk.js?a=1&b=2"></script><link rel="modulepreload" href="chunk.js?a=1&b=2"><script src="main.js"></script>`;
|
||||
assert.deepEqual(
|
||||
extractInitialResources(html).map((resource) => resource.url),
|
||||
['chunk.js?a=1&b=2', 'main.js']
|
||||
);
|
||||
});
|
||||
|
||||
test('SVG script elements are not HTML scripts, HTML inside foreignObject is', () => {
|
||||
const html = `<svg><script src="icon.js"></script><script href="icon2.js"></script><foreignObject><script src="html-in-svg.js"></script></foreignObject></svg><script src="main.js"></script>`;
|
||||
assert.deepEqual(
|
||||
extractInitialResources(html).map((resource) => resource.url),
|
||||
['html-in-svg.js', 'main.js']
|
||||
);
|
||||
});
|
||||
|
||||
test('counts a file once per distinct request URL', async () => {
|
||||
const distDir = await writeDist('cache-busted', {
|
||||
indexHtml: `<link rel="modulepreload" href="chunk-a.js"><link rel="modulepreload" href="chunk-a.js?v=2">`,
|
||||
files: { 'chunk-a.js': 100 },
|
||||
});
|
||||
const measurement = await measureInitialBytes({ distDir });
|
||||
assert.equal(measurement.resources.length, 2);
|
||||
assert.equal(measurement.totals.modulepreload, 200);
|
||||
});
|
||||
|
||||
test('sums index.html and every referenced file into the counter', async () => {
|
||||
const distDir = await writeDist('built');
|
||||
const measurement = await measureInitialBytes({ distDir });
|
||||
|
||||
const indexBytes = Buffer.byteLength(BUILT_INDEX_HTML);
|
||||
const script = 65 + 35876 + 1131437;
|
||||
const stylesheet = 311539;
|
||||
const modulepreload = 1566 + 529624;
|
||||
|
||||
assert.deepEqual(measurement.indexHtml, {
|
||||
path: 'index.html',
|
||||
bytes: indexBytes,
|
||||
});
|
||||
assert.equal(measurement.resources.length, 6);
|
||||
assert.deepEqual(measurement.totals, {
|
||||
indexHtml: indexBytes,
|
||||
script,
|
||||
stylesheet,
|
||||
modulepreload,
|
||||
initialBytes: indexBytes + script + stylesheet + modulepreload,
|
||||
});
|
||||
assert.deepEqual(measurement.counters, {
|
||||
[INITIAL_BYTES_COUNTER]:
|
||||
indexBytes + script + stylesheet + modulepreload,
|
||||
});
|
||||
});
|
||||
|
||||
test('fails when index.html is missing instead of reporting zero bytes', async () => {
|
||||
const distDir = await writeDist('no-index', { indexHtml: null, files: {} });
|
||||
await assert.rejects(
|
||||
measureInitialBytes({ distDir }),
|
||||
/No index\.html under .*no-index.*pnpm nx build web/
|
||||
);
|
||||
});
|
||||
|
||||
test('fails and names every referenced file that is missing from the build', async () => {
|
||||
const dropped = ['chunk-Cn2Agfvf.js', 'main-EI6PCDGR.js'];
|
||||
const files = Object.fromEntries(
|
||||
Object.entries(BUILT_FILES).filter(([file]) => !dropped.includes(file))
|
||||
);
|
||||
const distDir = await writeDist('missing-chunk', { files });
|
||||
await assert.rejects(
|
||||
measureInitialBytes({ distDir }),
|
||||
/not in .*missing-chunk: chunk-Cn2Agfvf\.js, main-EI6PCDGR\.js/
|
||||
);
|
||||
});
|
||||
|
||||
test('journey summary carries the counter under the launch journey', async () => {
|
||||
const distDir = await writeDist('summary');
|
||||
const measurement = await measureInitialBytes({ distDir });
|
||||
const summary = toJourneySummary(measurement, {
|
||||
measuredAt: new Date('2026-09-26T00:00:00.000Z'),
|
||||
});
|
||||
assert.deepEqual(summary, {
|
||||
version: 1,
|
||||
measuredAt: '2026-09-26T00:00:00.000Z',
|
||||
journeys: {
|
||||
launch: {
|
||||
counters: {
|
||||
[INITIAL_BYTES_COUNTER]: measurement.totals.initialBytes,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('report lists the largest files first and ends with the counter', async () => {
|
||||
const distDir = await writeDist('report');
|
||||
const report = formatReport(await measureInitialBytes({ distDir }));
|
||||
const lines = report.split('\n');
|
||||
const mainLine = lines.findIndex((line) =>
|
||||
line.startsWith('main-EI6PCDGR.js')
|
||||
);
|
||||
const configLine = lines.findIndex((line) =>
|
||||
line.startsWith('assets/app-config.js')
|
||||
);
|
||||
assert.ok(mainLine > 0 && mainLine < configLine);
|
||||
assert.match(
|
||||
lines.at(-1),
|
||||
/^renderer\.initialBytes = [\d,]+ bytes \(6 files \+ index\.html\)$/
|
||||
);
|
||||
});
|
||||
|
||||
test('parses CLI arguments and rejects unknown ones', () => {
|
||||
assert.deepEqual(parseArgs([]), {
|
||||
distDir: DEFAULT_DIST_DIR,
|
||||
json: false,
|
||||
summary: null,
|
||||
});
|
||||
assert.deepEqual(
|
||||
parseArgs(['--', '--dist', 'out', '--json', '--summary=s.json']),
|
||||
{
|
||||
distDir: 'out',
|
||||
json: true,
|
||||
summary: 's.json',
|
||||
}
|
||||
);
|
||||
assert.deepEqual(
|
||||
parseArgs(['--dist=out/web', '--summary', 'dist/s.json']).distDir,
|
||||
'out/web'
|
||||
);
|
||||
assert.throws(
|
||||
() => parseArgs(['--verbose']),
|
||||
/Unknown argument: --verbose/
|
||||
);
|
||||
assert.throws(() => parseArgs(['--dist']), /Missing value for --dist/);
|
||||
});
|
||||
|
||||
test('CLI writes the journey summary and exits 0 on a complete build', async () => {
|
||||
const distDir = await writeDist('cli');
|
||||
const summaryPath = path.join(workDir, 'out', 'journey-summary.json');
|
||||
const stdout = execFileSync(
|
||||
process.execPath,
|
||||
[scriptPath, '--dist', distDir, '--summary', summaryPath],
|
||||
{ encoding: 'utf8' }
|
||||
);
|
||||
assert.match(stdout, /renderer\.initialBytes = [\d,]+ bytes/);
|
||||
const summary = JSON.parse(await readFile(summaryPath, 'utf8'));
|
||||
assert.equal(
|
||||
summary.journeys.launch.counters[INITIAL_BYTES_COUNTER],
|
||||
Buffer.byteLength(BUILT_INDEX_HTML) +
|
||||
Object.values(BUILT_FILES).reduce((sum, bytes) => sum + bytes, 0)
|
||||
);
|
||||
});
|
||||
|
||||
test('CLI exits 1 with a readable message when the build is missing', () => {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[scriptPath, '--dist', path.join(workDir, 'does-not-exist')],
|
||||
{ encoding: 'utf8' }
|
||||
);
|
||||
assert.equal(result.status, 1);
|
||||
assert.match(result.stderr, /measure-initial-bytes: No index\.html under/);
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"name": "performance-tools",
|
||||
"projectType": "library",
|
||||
"sourceRoot": "tools/performance",
|
||||
"tags": ["scope:tools", "domain:performance", "type:tool"],
|
||||
"targets": {
|
||||
"test": {
|
||||
"executor": "nx:run-commands",
|
||||
"cache": true,
|
||||
"inputs": [
|
||||
"{projectRoot}/*.mjs",
|
||||
"{projectRoot}/*.json",
|
||||
{ "externalDependencies": ["parse5"] }
|
||||
],
|
||||
"options": {
|
||||
"command": "node --test tools/performance/measure-initial-bytes.test.mjs tools/performance/check-journey-ratchet.test.mjs tools/performance/check-baseline-direction.test.mjs",
|
||||
"cwd": "{workspaceRoot}"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"inputs": [
|
||||
"default",
|
||||
"{workspaceRoot}/eslint.config.mjs",
|
||||
"{workspaceRoot}/tools/eslint-rules/**/*",
|
||||
"{workspaceRoot}/tools/eslint/**/*"
|
||||
],
|
||||
"command": "eslint \"tools/performance/*.mjs\""
|
||||
}
|
||||
}
|
||||
}
|
||||