From 7521dc0a62d7372b8ad3ed7d96bd6c96eafee555 Mon Sep 17 00:00:00 2001 From: hugoer Date: Thu, 3 Sep 2026 22:32:17 +0200 Subject: [PATCH 1/6] fix(profiles): deep-freeze the exported preset objects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PROFILES, NETWORK_PRESETS and DEVICE_PRESETS are exported from web-perf-cli/profiles and read by resolveProfileSettings on every audit, so a consumer mutating one changes what every later run measures: PROFILES.low.network = 'wifi'; Every subsequent `lab --profile=low` then runs on WiFi instead of Regular 3G while the report still says the profile was "low". Nothing signals it, which makes it worse than a failed run. NETWORK_PRESETS is the sharpest case — buildThrottling reads rttMs, throughputKbps, uploadKbps and cpuSlowdownMultiplier straight out of it, the values the INVARIANT note is about. Object.freeze is the wrong depth here: it stops PROFILES.low = {...} but leaves PROFILES.low.network writable, and the nested write is the damaging one. A local deepFreeze recurses into each preset. It stays unexported — lib/utils.js is a published subpath, and a helper serving one module does not belong on the package's semver surface. The emitted declarations are unchanged by this commit; publishing the objects as readonly is a separate change. --- lib/profiles.js | 35 +++++++++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/lib/profiles.js b/lib/profiles.js index 90925b2..12ac394 100644 --- a/lib/profiles.js +++ b/lib/profiles.js @@ -10,12 +10,35 @@ const LAB_CATEGORIES = Object.freeze(['performance', 'accessibility', 'best-prac const DEVTOOLS_RTT_ADJUSTMENT_FACTOR = 3.75; const DEVTOOLS_THROUGHPUT_ADJUSTMENT_FACTOR = 0.9; +/** + * Recursively freezes an object and every nested object it owns. + * + * `Object.freeze` on its own is shallow, and shallow is the wrong depth for the presets + * below: it blocks `PROFILES.low = {...}` but leaves `PROFILES.low.network = 'wifi'` + * writable, which is the mutation that matters. Every later `--profile=low` audit would + * then run on WiFi while the report still reports the profile as "low" — a wrong number + * with nothing signalling it, which is worse than a failed run. + * + * @template {object} T + * @param {T} obj + * @returns {T} the same object, now frozen at every level + */ +function deepFreeze(obj) { + for (const value of Object.values(obj)) { + if (value !== null && typeof value === 'object') { + deepFreeze(value); + } + } + Object.freeze(obj); + return obj; +} + // INVARIANT: `throughputKbps` and `uploadKbps` are always the NOMINAL (pre-adjustment) // link speeds, matching how DevTools names its presets. buildThrottling() owns the // 3.75 / 0.9 factors and is the only place they may be applied. Storing an already // adjusted value here double-applies the factor — that bug shipped in '4g', whose // uploadKbps held 675 (= 750 * 0.9) and so emitted 607.5 instead of 675. -const NETWORK_PRESETS = { +const NETWORK_PRESETS = deepFreeze({ '3g-slow': { rttMs: 400, throughputKbps: 400, @@ -58,9 +81,9 @@ const NETWORK_PRESETS = { cpuSlowdownMultiplier: 1, label: 'No throttling', }, -}; +}); -const DEVICE_PRESETS = { +const DEVICE_PRESETS = deepFreeze({ 'moto-g-power': { width: 412, height: 823, @@ -109,9 +132,9 @@ const DEVICE_PRESETS = { formFactor: 'desktop', label: '1920x1080 @ 1x (desktop)', }, -}; +}); -const PROFILES = { +const PROFILES = deepFreeze({ low: { network: '3g', device: 'moto-g-power', @@ -132,7 +155,7 @@ const PROFILES = { device: null, label: 'Native device (no throttling, no emulation — actual hardware)', }, -}; +}); /** * Converts a nominal network preset into a Lighthouse `throttling` settings object, From 22d9d6cea9c23abae0c59f025f5e2d81d4cfdcd0 Mon Sep 17 00:00:00 2001 From: hugoer Date: Thu, 3 Sep 2026 22:34:44 +0200 Subject: [PATCH 2/6] fix(profiles): publish the preset objects as readonly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit froze the three preset maps at runtime; a consumer's TypeScript build still saw them as mutable object literals, so the assignment that now throws compiled cleanly. Each map is annotated as `Readonly>>` over a named typedef — NetworkPreset, DevicePreset, LabProfile. The properties are all primitives, so shallow Readonly on the entry is deep enough here. Naming the entry shapes also shrinks the declarations: they described a preset once per key before, six times over for NETWORK_PRESETS and DEVICE_PRESETS. Two side effects of the annotations are improvements rather than accidents. PROFILES had been emitted as a namespace of `let` bindings with generated aliases (network_1, network_2 …) because it was a bare object literal; it is now a single const. DevicePreset.formFactor narrows from string to 'mobile' | 'desktop', which is what the two values have always been. The three types are re-declared in lib/index.js and listed in the README's "Key exported types" table, alongside a note on why the maps are frozen and how to build a variant by spreading. --- README.md | 14 +++ lib/index.js | 3 + lib/profiles.js | 36 +++++++ types/lib/index.d.ts | 20 +++- types/lib/profiles.d.ts | 211 +++++++++++++++++----------------------- 5 files changed, 160 insertions(+), 124 deletions(-) diff --git a/README.md b/README.md index c2d0848..c761418 100644 --- a/README.md +++ b/README.md @@ -553,8 +553,22 @@ Key exported types: | `LabWriteOptions` | `LabAuditOptions` plus `clean` — what `runLabToDisk` takes | | `LabPlanControls` | Plan-level controls only: `continueOnError`, `reuseBrowser`, `repeats` | | `LabPlanOptions` | `LabPlanControls` plus the per-run options, minus `runNumber` and `port`, which `runLabPlan` owns | +| `LabProfile` | One `PROFILES` entry: `{ network, device, label }`, both keys `null` for the `native` profile | +| `NetworkPreset` | One `NETWORK_PRESETS` entry: nominal `rttMs`, `throughputKbps`, `uploadKbps`, `cpuSlowdownMultiplier`, `label` | +| `DevicePreset` | One `DEVICE_PRESETS` entry: `width`, `height`, `deviceScaleFactor`, `mobile`, `formFactor`, `label` | | `RunSummary` | Variance record for one repeated (URL x profile) pair: median, spread, `benchmarkIndex` range, per-metric arrays, stability warnings | +`PROFILES`, `NETWORK_PRESETS` and `DEVICE_PRESETS` (from `web-perf-cli/profiles`) are frozen +at every level and published as `readonly`, so `PROFILES.low.network = 'wifi'` does not +compile, and at runtime throws under strict mode (ESM, or `'use strict'`) rather than taking +effect. `resolveProfileSettings` reads them on every audit, and a +mutation there would silently change what later runs measure while the report still named the +original profile. Build a variant by spreading instead: + +```js +const custom = { ...NETWORK_PRESETS['3g'], rttMs: 250 }; +``` + ## Development ```bash diff --git a/lib/index.js b/lib/index.js index 3f84ee1..965c4a9 100644 --- a/lib/index.js +++ b/lib/index.js @@ -13,6 +13,9 @@ * @typedef {import('./crux').CruxFormFactor} CruxFormFactor * @typedef {import('./crux-history').CruxHistoryReport} CruxHistoryReport * @typedef {import('./crux-history').CruxHistoryBatchResult} CruxHistoryBatchResult + * @typedef {import('./profiles').LabProfile} LabProfile + * @typedef {import('./profiles').NetworkPreset} NetworkPreset + * @typedef {import('./profiles').DevicePreset} DevicePreset * @typedef {import('./variance').RunSummary} RunSummary */ diff --git a/lib/profiles.js b/lib/profiles.js index 12ac394..8ba2a5d 100644 --- a/lib/profiles.js +++ b/lib/profiles.js @@ -38,6 +38,19 @@ function deepFreeze(obj) { // 3.75 / 0.9 factors and is the only place they may be applied. Storing an already // adjusted value here double-applies the factor — that bug shipped in '4g', whose // uploadKbps held 675 (= 750 * 0.9) and so emitted 607.5 instead of 675. +/** + * One entry in NETWORK_PRESETS. Named rather than inline so the published declarations + * describe a preset once instead of repeating its structure per key. + * + * @typedef {Object} NetworkPreset + * @property {number} rttMs - nominal round-trip time, in milliseconds + * @property {number} throughputKbps - nominal download speed (see the INVARIANT above) + * @property {number} uploadKbps - nominal upload speed (see the INVARIANT above) + * @property {number} cpuSlowdownMultiplier + * @property {string} label - the one-line summary printed by `list-profiles` + */ + +/** @type {Readonly>>} */ const NETWORK_PRESETS = deepFreeze({ '3g-slow': { rttMs: 400, @@ -83,6 +96,19 @@ const NETWORK_PRESETS = deepFreeze({ }, }); +/** + * One entry in DEVICE_PRESETS: the screen emulation and form factor for a device. + * + * @typedef {Object} DevicePreset + * @property {number} width + * @property {number} height + * @property {number} deviceScaleFactor + * @property {boolean} mobile + * @property {'mobile'|'desktop'} formFactor + * @property {string} label - the one-line summary printed by `list-profiles` + */ + +/** @type {Readonly>>} */ const DEVICE_PRESETS = deepFreeze({ 'moto-g-power': { width: 412, @@ -134,6 +160,16 @@ const DEVICE_PRESETS = deepFreeze({ }, }); +/** + * One entry in PROFILES: a named pairing of a network preset with a device preset. + * + * @typedef {Object} LabProfile + * @property {string|null} network - a NETWORK_PRESETS key, or null for the native profile + * @property {string|null} device - a DEVICE_PRESETS key, or null for the native profile + * @property {string} label - the one-line summary printed by `list-profiles` + */ + +/** @type {Readonly>>} */ const PROFILES = deepFreeze({ low: { network: '3g', diff --git a/types/lib/index.d.ts b/types/lib/index.d.ts index 8d58139..eab7e0b 100644 --- a/types/lib/index.d.ts +++ b/types/lib/index.d.ts @@ -1,5 +1,5 @@ declare namespace _exports { - export { LabReport, LabPlanResult, PsiReport, PsiBatchResult, CruxReport, CruxMetric, CruxBatchResult, CruxFormFactor, CruxHistoryReport, CruxHistoryBatchResult, RunSummary }; + export { LabReport, LabPlanResult, PsiReport, PsiBatchResult, CruxReport, CruxMetric, CruxBatchResult, CruxFormFactor, CruxHistoryReport, CruxHistoryBatchResult, LabProfile, NetworkPreset, DevicePreset, RunSummary }; } declare namespace _exports { const runLabAudit: typeof import("./lab").runLabAudit; @@ -87,6 +87,24 @@ type CruxHistoryReport = import("./crux-history").CruxHistoryReport; * documented `import type { CruxReport } from '@hugoer/web-perf-cli'` fails with TS2305. */ type CruxHistoryBatchResult = import("./crux-history").CruxHistoryBatchResult; +/** + * The types the README's "Key exported types" table promises from the package root. Re-declared + * here because lib/index.js is a value-only façade of lazy getters: without these, the + * documented `import type { CruxReport } from '@hugoer/web-perf-cli'` fails with TS2305. + */ +type LabProfile = import("./profiles").LabProfile; +/** + * The types the README's "Key exported types" table promises from the package root. Re-declared + * here because lib/index.js is a value-only façade of lazy getters: without these, the + * documented `import type { CruxReport } from '@hugoer/web-perf-cli'` fails with TS2305. + */ +type NetworkPreset = import("./profiles").NetworkPreset; +/** + * The types the README's "Key exported types" table promises from the package root. Re-declared + * here because lib/index.js is a value-only façade of lazy getters: without these, the + * documented `import type { CruxReport } from '@hugoer/web-perf-cli'` fails with TS2305. + */ +type DevicePreset = import("./profiles").DevicePreset; /** * The types the README's "Key exported types" table promises from the package root. Re-declared * here because lib/index.js is a value-only façade of lazy getters: without these, the diff --git a/types/lib/profiles.d.ts b/types/lib/profiles.d.ts index 7b44fec..7718889 100644 --- a/types/lib/profiles.d.ts +++ b/types/lib/profiles.d.ts @@ -1,128 +1,93 @@ -export namespace PROFILES { - namespace low { - let network: string; - let device: string; - let label: string; - } - namespace medium { - let network_1: string; - export { network_1 as network }; - let device_1: string; - export { device_1 as device }; - let label_1: string; - export { label_1 as label }; - } - namespace high { - let network_2: string; - export { network_2 as network }; - let device_2: string; - export { device_2 as device }; - let label_2: string; - export { label_2 as label }; - } - namespace native { - let network_3: null; - export { network_3 as network }; - let device_3: null; - export { device_3 as device }; - let label_3: string; - export { label_3 as label }; - } -} -export const NETWORK_PRESETS: { - '3g-slow': { - rttMs: number; - throughputKbps: number; - uploadKbps: number; - cpuSlowdownMultiplier: number; - label: string; - }; - '3g': { - rttMs: number; - throughputKbps: number; - uploadKbps: number; - cpuSlowdownMultiplier: number; - label: string; - }; - '4g': { - rttMs: number; - throughputKbps: number; - uploadKbps: number; - cpuSlowdownMultiplier: number; - label: string; - }; - '4g-fast': { - rttMs: number; - throughputKbps: number; - uploadKbps: number; - cpuSlowdownMultiplier: number; - label: string; - }; - wifi: { - rttMs: number; - throughputKbps: number; - uploadKbps: number; - cpuSlowdownMultiplier: number; - label: string; - }; - none: { - rttMs: number; - throughputKbps: number; - uploadKbps: number; - cpuSlowdownMultiplier: number; - label: string; - }; +/** + * One entry in NETWORK_PRESETS. Named rather than inline so the published declarations + * describe a preset once instead of repeating its structure per key. + */ +export type NetworkPreset = { + /** + * - nominal round-trip time, in milliseconds + */ + rttMs: number; + /** + * - nominal download speed (see the INVARIANT above) + */ + throughputKbps: number; + /** + * - nominal upload speed (see the INVARIANT above) + */ + uploadKbps: number; + cpuSlowdownMultiplier: number; + /** + * - the one-line summary printed by `list-profiles` + */ + label: string; }; -export const DEVICE_PRESETS: { - 'moto-g-power': { - width: number; - height: number; - deviceScaleFactor: number; - mobile: boolean; - formFactor: string; - label: string; - }; - 'iphone-12': { - width: number; - height: number; - deviceScaleFactor: number; - mobile: boolean; - formFactor: string; - label: string; - }; - 'iphone-14': { - width: number; - height: number; - deviceScaleFactor: number; - mobile: boolean; - formFactor: string; - label: string; - }; - ipad: { - width: number; - height: number; - deviceScaleFactor: number; - mobile: boolean; - formFactor: string; - label: string; - }; - desktop: { - width: number; - height: number; - deviceScaleFactor: number; - mobile: boolean; - formFactor: string; - label: string; - }; - 'desktop-large': { - width: number; - height: number; - deviceScaleFactor: number; - mobile: boolean; - formFactor: string; - label: string; - }; +/** + * One entry in DEVICE_PRESETS: the screen emulation and form factor for a device. + */ +export type DevicePreset = { + width: number; + height: number; + deviceScaleFactor: number; + mobile: boolean; + formFactor: "mobile" | "desktop"; + /** + * - the one-line summary printed by `list-profiles` + */ + label: string; }; +/** + * One entry in PROFILES: a named pairing of a network preset with a device preset. + */ +export type LabProfile = { + /** + * - a NETWORK_PRESETS key, or null for the native profile + */ + network: string | null; + /** + * - a DEVICE_PRESETS key, or null for the native profile + */ + device: string | null; + /** + * - the one-line summary printed by `list-profiles` + */ + label: string; +}; +/** + * One entry in PROFILES: a named pairing of a network preset with a device preset. + * + * @typedef {Object} LabProfile + * @property {string|null} network - a NETWORK_PRESETS key, or null for the native profile + * @property {string|null} device - a DEVICE_PRESETS key, or null for the native profile + * @property {string} label - the one-line summary printed by `list-profiles` + */ +/** @type {Readonly>>} */ +export const PROFILES: Readonly>>; +/** + * One entry in NETWORK_PRESETS. Named rather than inline so the published declarations + * describe a preset once instead of repeating its structure per key. + * + * @typedef {Object} NetworkPreset + * @property {number} rttMs - nominal round-trip time, in milliseconds + * @property {number} throughputKbps - nominal download speed (see the INVARIANT above) + * @property {number} uploadKbps - nominal upload speed (see the INVARIANT above) + * @property {number} cpuSlowdownMultiplier + * @property {string} label - the one-line summary printed by `list-profiles` + */ +/** @type {Readonly>>} */ +export const NETWORK_PRESETS: Readonly>>; +/** + * One entry in DEVICE_PRESETS: the screen emulation and form factor for a device. + * + * @typedef {Object} DevicePreset + * @property {number} width + * @property {number} height + * @property {number} deviceScaleFactor + * @property {boolean} mobile + * @property {'mobile'|'desktop'} formFactor + * @property {string} label - the one-line summary printed by `list-profiles` + */ +/** @type {Readonly>>} */ +export const DEVICE_PRESETS: Readonly>>; export const LAB_CATEGORIES: readonly string[]; export const DEVTOOLS_RTT_ADJUSTMENT_FACTOR: 3.75; export const DEVTOOLS_THROUGHPUT_ADJUSTMENT_FACTOR: 0.9; From baea276c0301fa9327257a42ce4505fa335de45d Mon Sep 17 00:00:00 2001 From: hugoer Date: Thu, 3 Sep 2026 22:37:32 +0200 Subject: [PATCH 3/6] test: assert the preset objects are deeply frozen and published readonly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each of the three maps is asserted five ways at runtime and five ways in type-tests: read a nested value, spread an entry into a variant, fail to write a nested property, fail to replace a whole entry, and — at runtime — confirm resolveProfileSettings still resolves to the same numbers. lib/profiles.test.js is an ES module, so it runs in strict mode and a frozen write throws rather than failing silently. A sloppy-mode CommonJS consumer gets the silent no-op instead; either way the mutation never lands, which is the property under test. Eleven mutations, all caught, none survived: - deepFreeze stops recursing -> 10 runtime tests fail - each map left unfrozen (x3) -> 4-5 runtime tests fail - entry type loses Readonly (x3) -> TS2578 unused @ts-expect-error - map type loses Readonly (x3) -> TS2578 unused @ts-expect-error - DevicePreset.formFactor widened -> TS2322 at the assignment The two Readonly layers are tested separately because they block different writes: dropping the inner one re-opens PROFILES.low.network, dropping the outer one re-opens PROFILES.low. Only the inner failure is silent, which is why it is the one worth a dedicated mutation. type-tests/root.ts gains the three new types, keeping its stated invariant true: every row of the README's "Key exported types" table resolves from the root. --- lib/profiles.test.js | 55 ++++++++++++++++++++++++++++++++++ type-tests/frozen-constants.ts | 35 +++++++++++++++++++++- type-tests/root.ts | 2 ++ 3 files changed, 91 insertions(+), 1 deletion(-) diff --git a/lib/profiles.test.js b/lib/profiles.test.js index 07c99b0..766c4b7 100644 --- a/lib/profiles.test.js +++ b/lib/profiles.test.js @@ -5,7 +5,9 @@ const { buildScreenEmulation, resolveProfileSettings, LAB_CATEGORIES, + PROFILES, NETWORK_PRESETS, + DEVICE_PRESETS, DEVTOOLS_RTT_ADJUSTMENT_FACTOR, DEVTOOLS_THROUGHPUT_ADJUSTMENT_FACTOR, } = require('./profiles'); @@ -220,3 +222,56 @@ describe('resolveProfileSettings', () => { expect(result.emulatedUserAgent).not.toContain('Mobile'); }); }); + +// This file is an ES module, so it runs in strict mode and a write to a frozen object throws +// instead of failing silently. A consumer in sloppy-mode CommonJS gets the silent no-op — +// still a mutation that never lands, which is the property these tests are about. +describe('exported preset objects are deeply frozen', () => { + const cases = [ + ['PROFILES', PROFILES, 'low', 'network', 'wifi'], + ['NETWORK_PRESETS', NETWORK_PRESETS, '3g', 'rttMs', 1], + ['DEVICE_PRESETS', DEVICE_PRESETS, 'iphone-12', 'width', 9999], + ]; + + it.each(cases)('%s is frozen at both levels', (_name, obj) => { + expect(Object.isFrozen(obj)).toBe(true); + for (const entry of Object.values(obj)) { + expect(Object.isFrozen(entry)).toBe(true); + } + }); + + // Object.freeze on its own would pass the test above for the container and fail this one: + // it leaves every nested preset writable, and the nested write is the damaging one. + it.each(cases)('%s rejects a nested write', (_name, obj, key, prop, value) => { + const before = obj[key][prop]; + expect(() => { + obj[key][prop] = value; + }).toThrow(TypeError); + expect(obj[key][prop]).toBe(before); + }); + + it.each(cases)('%s rejects replacing a whole entry', (_name, obj, key) => { + expect(() => { + obj[key] = {}; + }).toThrow(TypeError); + expect(obj[key]).toBeTypeOf('object'); + }); + + it.each(cases)('%s still reads nested values', (_name, obj, key, prop) => { + expect(obj[key][prop]).toBeDefined(); + }); + + it.each(cases)('%s can still be extended through a copy', (_name, obj, key, prop, value) => { + const variant = { ...obj[key], [prop]: value }; + expect(variant[prop]).toBe(value); + expect(obj[key][prop]).not.toBe(value); + }); + + // The freeze must not change what an audit resolves to — the presets are still the values + // resolveProfileSettings reads on every run. + it('leaves resolveProfileSettings reading the same values', () => { + const settings = resolveProfileSettings({ profile: 'low' }); + expect(settings.throttling.rttMs).toBe(NETWORK_PRESETS['3g'].rttMs); + expect(settings.screenEmulation.width).toBe(DEVICE_PRESETS['moto-g-power'].width); + }); +}); diff --git a/type-tests/frozen-constants.ts b/type-tests/frozen-constants.ts index fbdd4b1..ec94ae0 100644 --- a/type-tests/frozen-constants.ts +++ b/type-tests/frozen-constants.ts @@ -9,7 +9,8 @@ import { runPsi, runPsiBatch, PSI_STRATEGIES, DEFAULT_PSI_STRATEGIES } from '@hugoer/web-perf-cli/psi'; import { runCrux, DEFAULT_CRUX_FORM_FACTORS } from '@hugoer/web-perf-cli/crux'; import { buildLighthouseConfig, CHROME_FLAGS, DEFAULT_SKIP_AUDITS } from '@hugoer/web-perf-cli/lab'; -import { LAB_CATEGORIES } from '@hugoer/web-perf-cli/profiles'; +import { LAB_CATEGORIES, PROFILES, NETWORK_PRESETS, DEVICE_PRESETS } from '@hugoer/web-perf-cli/profiles'; +import type { LabProfile, NetworkPreset, DevicePreset } from '@hugoer/web-perf-cli/profiles'; // --- PSI strategies ------------------------------------------------------------------------- export const psiDefault = runPsi('https://example.com', 'KEY', undefined, { @@ -48,3 +49,35 @@ CHROME_FLAGS.push('--headless=new'); export const categories: string[] = [...LAB_CATEGORIES]; // @ts-expect-error LAB_CATEGORIES is frozen and published as readonly LAB_CATEGORIES.push('performance'); + +// --- profile, network and device presets ---------------------------------------------------- +// These are objects, not arrays, so the mutation that matters is one level down: freezing the +// container alone leaves PROFILES.low.network writable, and that write changes what every +// later audit measures while the report still names the original profile. +export const profile: LabProfile = PROFILES.low; +export const network: NetworkPreset = NETWORK_PRESETS['3g']; +export const device: DevicePreset = DEVICE_PRESETS['iphone-12']; + +export const rttMs: number = NETWORK_PRESETS['3g'].rttMs; +export const formFactor: 'mobile' | 'desktop' = DEVICE_PRESETS.desktop.formFactor; +export const profileNetwork: string | null = PROFILES.native.network; + +// The supported way to build a variant: spread, do not mutate. +export const slowerThan3g: NetworkPreset = { ...NETWORK_PRESETS['3g'], rttMs: 250 }; +export const taller: DevicePreset = { ...DEVICE_PRESETS['iphone-12'], height: 1000 }; +export const onWifi: LabProfile = { ...PROFILES.low, network: 'wifi' }; + +// @ts-expect-error PROFILES entries are frozen and published as readonly +PROFILES.low.network = 'wifi'; +// @ts-expect-error NETWORK_PRESETS entries are frozen and published as readonly +NETWORK_PRESETS['3g'].rttMs = 1; +// @ts-expect-error DEVICE_PRESETS entries are frozen and published as readonly +DEVICE_PRESETS['iphone-12'].width = 9999; + +// Replacing a whole entry is blocked too — the container itself is readonly. +// @ts-expect-error PROFILES is frozen and published as readonly +PROFILES.low = onWifi; +// @ts-expect-error NETWORK_PRESETS is frozen and published as readonly +NETWORK_PRESETS['3g'] = slowerThan3g; +// @ts-expect-error DEVICE_PRESETS is frozen and published as readonly +DEVICE_PRESETS['iphone-12'] = taller; diff --git a/type-tests/root.ts b/type-tests/root.ts index 6d00d61..4ff50b7 100644 --- a/type-tests/root.ts +++ b/type-tests/root.ts @@ -7,6 +7,7 @@ import type { LabReport, LabPlanResult, PsiReport, PsiBatchResult, CruxReport, CruxMetric, CruxBatchResult, CruxFormFactor, CruxHistoryReport, CruxHistoryBatchResult, RunSummary, + LabProfile, NetworkPreset, DevicePreset, } from '@hugoer/web-perf-cli'; export const fns = [runCruxAudit, runPsiAudit, runLabAudit, normalizeOrigin]; @@ -17,4 +18,5 @@ export type Rows = [ LabReport, LabPlanResult, PsiReport, PsiBatchResult, CruxReport, CruxMetric, CruxBatchResult, CruxFormFactor, CruxHistoryReport, CruxHistoryBatchResult, RunSummary, + LabProfile, NetworkPreset, DevicePreset, ]; From eeb855ec67f5ee47e307dc31a981ff168a8f6b8f Mon Sep 17 00:00:00 2001 From: hugoer Date: Fri, 4 Sep 2026 09:36:22 +0200 Subject: [PATCH 4/6] fix(profiles): make deepFreeze terminate on cyclic input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deepFreeze froze the parent AFTER recursing into its values, so a self-referential object recursed forever: const a = { child: {} }; a.child.parent = a; deepFreeze(a); // RangeError: Maximum call stack size exceeded Not reachable from this module — the three preset maps are literals with no cycles and no shared references — but the helper is written generically and reads as a utility, so the next caller would have inherited it. Freezing the parent first is what makes an Object.isFrozen guard work at all: with the original order the parent is still unfrozen when the recursion re-enters it, so the guard never fires and the cycle is not broken. Reordered, the guard both terminates the cycle and skips subtrees that are already frozen. Verified against `a.child.parent = a`: the old body raises RangeError, the new one returns with both levels frozen. --- lib/profiles.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/lib/profiles.js b/lib/profiles.js index 8ba2a5d..ef42881 100644 --- a/lib/profiles.js +++ b/lib/profiles.js @@ -19,17 +19,22 @@ const DEVTOOLS_THROUGHPUT_ADJUSTMENT_FACTOR = 0.9; * then run on WiFi while the report still reports the profile as "low" — a wrong number * with nothing signalling it, which is worse than a failed run. * + * The parent is frozen BEFORE recursing, which is what makes the `Object.isFrozen` guard + * terminate on a self-referential object: freezing afterwards would leave the parent + * unfrozen when the recursion re-entered it, and the guard would never fire. The guard + * also skips subtrees that are already frozen. + * * @template {object} T * @param {T} obj * @returns {T} the same object, now frozen at every level */ function deepFreeze(obj) { + Object.freeze(obj); for (const value of Object.values(obj)) { - if (value !== null && typeof value === 'object') { + if (value !== null && typeof value === 'object' && !Object.isFrozen(value)) { deepFreeze(value); } } - Object.freeze(obj); return obj; } From 620bd1ec82b5199ee4a53cf954e3ff50cec1134e Mon Sep 17 00:00:00 2001 From: hugoer Date: Fri, 4 Sep 2026 09:36:29 +0200 Subject: [PATCH 5/6] test(profiles): assert the entry survives a rejected whole-entry write MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "rejects replacing a whole entry" case guarded with toBeTypeOf('object'), which `{}` satisfies — the exact value the write attempts. The assertion could not fail for the mutation the case is named after. Capturing the entry first and asserting identity against it does. This changes no outcome under ESM, where the write throws and toThrow already carries the case; it matters for the sloppy-mode CommonJS consumer the block's header comment reasons about, where the write is a silent no-op and the identity check is the only assertion left standing. The sibling nested-write case already worked this way. Re-checked against the map-left-unfrozen mutation: still 5 failures for PROFILES. --- lib/profiles.test.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/profiles.test.js b/lib/profiles.test.js index 766c4b7..ad3b306 100644 --- a/lib/profiles.test.js +++ b/lib/profiles.test.js @@ -245,16 +245,20 @@ describe('exported preset objects are deeply frozen', () => { it.each(cases)('%s rejects a nested write', (_name, obj, key, prop, value) => { const before = obj[key][prop]; expect(() => { - obj[key][prop] = value; + obj[key][prop] = value; }).toThrow(TypeError); expect(obj[key][prop]).toBe(before); }); + // The identity check is the assertion that carries this case for a sloppy-mode consumer, + // where the write is a silent no-op and never throws: `{}` is still an object, so only + // comparing against the entry held before the write proves the replacement did not land. it.each(cases)('%s rejects replacing a whole entry', (_name, obj, key) => { + const before = obj[key]; expect(() => { - obj[key] = {}; + obj[key] = {}; }).toThrow(TypeError); - expect(obj[key]).toBeTypeOf('object'); + expect(obj[key]).toBe(before); }); it.each(cases)('%s still reads nested values', (_name, obj, key, prop) => { From 132d4a4dc31365023749d0643e3074c81aacbe83 Mon Sep 17 00:00:00 2001 From: hugoer Date: Fri, 4 Sep 2026 09:36:38 +0200 Subject: [PATCH 6/6] docs: document every frozen exported constant, and drop a dangling type reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two consumer-facing documentation gaps around the frozen constants. README described the three preset objects as frozen and said nothing about the seven frozen arrays — CHROME_FLAGS, DEFAULT_SKIP_AUDITS, LAB_CATEGORIES, PSI_STRATEGIES, DEFAULT_PSI_STRATEGIES, CRUX_FORM_FACTORS, DEFAULT_CRUX_FORM_FACTORS. Nothing had documented those since they were frozen, so the note read as if they were still mutable. It now opens on the exported constants as a set, then keeps the deep-freeze rationale in its own paragraph, since only the objects need it. The list was checked against Object.freeze call sites and each module's exports: DEFAULT_PSI_CATEGORIES is frozen but unexported, so it is deliberately absent. NetworkPreset's throughputKbps and uploadKbps pointed at "the INVARIANT above". That note is a // line comment, which tsc drops, so the published declarations carried a reference to nothing — and the declarations are what a consumer reads on hover. Both descriptions are now self-contained and name buildThrottling as the owner of the factors. The buildThrottling reference that survives in the emitted .d.ts names the note's location rather than pointing "above", so it still resolves. --- README.md | 18 ++++++++++++------ lib/profiles.js | 4 ++-- types/lib/profiles.d.ts | 8 ++++---- 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index c761418..bc33c92 100644 --- a/README.md +++ b/README.md @@ -558,15 +558,21 @@ Key exported types: | `DevicePreset` | One `DEVICE_PRESETS` entry: `width`, `height`, `deviceScaleFactor`, `mobile`, `formFactor`, `label` | | `RunSummary` | Variance record for one repeated (URL x profile) pair: median, spread, `benchmarkIndex` range, per-metric arrays, stability warnings | -`PROFILES`, `NETWORK_PRESETS` and `DEVICE_PRESETS` (from `web-perf-cli/profiles`) are frozen -at every level and published as `readonly`, so `PROFILES.low.network = 'wifi'` does not -compile, and at runtime throws under strict mode (ESM, or `'use strict'`) rather than taking -effect. `resolveProfileSettings` reads them on every audit, and a -mutation there would silently change what later runs measure while the report still named the -original profile. Build a variant by spreading instead: +Every exported constant is frozen and published as `readonly`: the arrays `CHROME_FLAGS`, +`DEFAULT_SKIP_AUDITS`, `LAB_CATEGORIES`, `PSI_STRATEGIES`, `DEFAULT_PSI_STRATEGIES`, +`CRUX_FORM_FACTORS` and `DEFAULT_CRUX_FORM_FACTORS`, plus the three preset objects +`PROFILES`, `NETWORK_PRESETS` and `DEVICE_PRESETS` (from `web-perf-cli/profiles`). Mutating +any of them fails to compile, and at runtime throws under strict mode (ESM, or `'use strict'`) +rather than taking effect. + +The three objects are frozen at *every* level, because the damaging write is one step down: +`resolveProfileSettings` reads the presets on every audit, so `PROFILES.low.network = 'wifi'` +would silently change what later runs measure while the report still named the original +profile. Build a variant by spreading instead: ```js const custom = { ...NETWORK_PRESETS['3g'], rttMs: 250 }; +const onWifi = { ...PROFILES.low, network: 'wifi' }; ``` ## Development diff --git a/lib/profiles.js b/lib/profiles.js index ef42881..185bc7e 100644 --- a/lib/profiles.js +++ b/lib/profiles.js @@ -49,8 +49,8 @@ function deepFreeze(obj) { * * @typedef {Object} NetworkPreset * @property {number} rttMs - nominal round-trip time, in milliseconds - * @property {number} throughputKbps - nominal download speed (see the INVARIANT above) - * @property {number} uploadKbps - nominal upload speed (see the INVARIANT above) + * @property {number} throughputKbps - nominal (pre-adjustment) download speed; `buildThrottling` applies the DevTools factors + * @property {number} uploadKbps - nominal (pre-adjustment) upload speed; `buildThrottling` applies the DevTools factors * @property {number} cpuSlowdownMultiplier * @property {string} label - the one-line summary printed by `list-profiles` */ diff --git a/types/lib/profiles.d.ts b/types/lib/profiles.d.ts index 7718889..5acbbc2 100644 --- a/types/lib/profiles.d.ts +++ b/types/lib/profiles.d.ts @@ -8,11 +8,11 @@ export type NetworkPreset = { */ rttMs: number; /** - * - nominal download speed (see the INVARIANT above) + * - nominal (pre-adjustment) download speed; `buildThrottling` applies the DevTools factors */ throughputKbps: number; /** - * - nominal upload speed (see the INVARIANT above) + * - nominal (pre-adjustment) upload speed; `buildThrottling` applies the DevTools factors */ uploadKbps: number; cpuSlowdownMultiplier: number; @@ -68,8 +68,8 @@ export const PROFILES: Readonly