diff --git a/lib/lab.js b/lib/lab.js index 6299305..3f5421d 100644 --- a/lib/lab.js +++ b/lib/lab.js @@ -10,9 +10,18 @@ const { ensureCommandDir, buildFilename } = require('./utils'); const { buildRunSummary, runtimeErrorOf } = require('./variance'); // Audits skipped by default — derived from SKIPPABLE_AUDITS to avoid duplication -const DEFAULT_SKIP_AUDITS = SKIPPABLE_AUDITS.filter((a) => a.defaultSkip).map((a) => a.id); +// Frozen: exported from web-perf-cli/lab and the package root, and the fallback inside +// buildLighthouseConfig. A consumer pushing to it would silently skip an extra audit in every +// later run. +const DEFAULT_SKIP_AUDITS = Object.freeze(SKIPPABLE_AUDITS.filter((a) => a.defaultSkip).map((a) => a.id)); -const CHROME_FLAGS = [ +// Frozen: exported and handed to every Chrome launch in this module and in links.js. A +// consumer appending a flag here would change how every later audit launches the browser, +// which invalidates scores rather than merely costing quota. +// +// chrome-launcher types chromeFlags as a mutable Array, so the three call sites spread +// it. Consumers passing CHROME_FLAGS to chromeLauncher.launch() must do the same. +const CHROME_FLAGS = Object.freeze([ '--headless', // run Chrome in headless mode (no UI) '--disable-gpu', // disable GPU hardware acceleration '--no-sandbox', // disable Chrome's sandbox (needed for some CI environments) @@ -24,7 +33,7 @@ const CHROME_FLAGS = [ '--disable-translate', // disable translation prompts '--mute-audio', // mute audio output, '--ignore-certificate-errors', // ignore certificate errors (useful for testing sites with self-signed certs) -]; +]); /** * @typedef {Object} LighthouseAudit @@ -179,7 +188,7 @@ async function runLabAudit(url, labOptions = {}) { ]); const profileSettings = resolveProfileSettings(labOptions); const externalPort = labOptions.port; - const chrome = externalPort ? null : await chromeLauncher.launch({ chromeFlags: CHROME_FLAGS }); + const chrome = externalPort ? null : await chromeLauncher.launch({ chromeFlags: [...CHROME_FLAGS] }); const port = externalPort || chrome.port; try { @@ -319,7 +328,7 @@ async function runLabPlan(urls, runs, options = {}, hooks = {}) { // exists only because lighthouse v13 is ESM-only. Required lazily to keep it off the // startup path of commands that never launch a browser. const chrome = reuseBrowser - ? await launchFn({ chromeFlags: CHROME_FLAGS }) + ? await launchFn({ chromeFlags: [...CHROME_FLAGS] }) : null; try { diff --git a/lib/links.js b/lib/links.js index a7e2734..382ac59 100644 --- a/lib/links.js +++ b/lib/links.js @@ -15,7 +15,7 @@ async function runLinks(url) { // The same flags lab launches with. Launching on '--headless' alone left links without // --no-sandbox and --disable-dev-shm-usage, so it failed in the Docker and CI // environments where lab works. - const chrome = await chromeLauncher.launch({ chromeFlags: CHROME_FLAGS }); + const chrome = await chromeLauncher.launch({ chromeFlags: [...CHROME_FLAGS] }); try { const browser = await puppeteer.connect({ diff --git a/lib/profiles.js b/lib/profiles.js index 6e8fcfc..90925b2 100644 --- a/lib/profiles.js +++ b/lib/profiles.js @@ -1,7 +1,9 @@ const MOBILE_UA = 'Mozilla/5.0 (Linux; Android 11; moto g power (2022)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Mobile Safari/537.36'; const DESKTOP_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36'; -const LAB_CATEGORIES = ['performance', 'accessibility', 'best-practices', 'seo', 'agentic-browsing']; +// Frozen: exported from web-perf-cli/profiles and used to validate --category input. A +// consumer mutating it would change which categories the CLI accepts. +const LAB_CATEGORIES = Object.freeze(['performance', 'accessibility', 'best-practices', 'seo', 'agentic-browsing']); // Lantern's DevTools-emulation factors. Mirrors // @paulirish/trace_engine/models/trace/lantern/simulation/Constants.js — keep in sync. diff --git a/lib/psi.js b/lib/psi.js index b4dd341..d1a41dc 100644 --- a/lib/psi.js +++ b/lib/psi.js @@ -6,9 +6,14 @@ const { ensureCommandDir, buildFilename, withRetry, runBatch } = require('./util const PSI_API_URL = 'https://www.googleapis.com/pagespeedonline/v5/runPagespeed'; const PSI_MAX_REQUESTS_PER_SECOND = 4; -const PSI_STRATEGIES = ['mobile', 'desktop']; -const DEFAULT_PSI_STRATEGIES = ['mobile', 'desktop']; -const DEFAULT_PSI_CATEGORIES = ['PERFORMANCE', 'ACCESSIBILITY', 'BEST_PRACTICES', 'SEO']; +// Frozen: all three are default parameter values, and the first two are exported from +// web-perf-cli/psi. A consumer pushing to DEFAULT_PSI_STRATEGIES would add a strategy to every +// later runPsi, runPsiBatch and runPsiAuditBatch call in the process — an extra API request +// per URL against the 25,000/day quota. Each carries its element type so the exported constant +// can still be passed to the option it is the default for. +const PSI_STRATEGIES = Object.freeze(/** @type {readonly PsiStrategy[]} */ (['mobile', 'desktop'])); +const DEFAULT_PSI_STRATEGIES = Object.freeze(/** @type {readonly PsiStrategy[]} */ (['mobile', 'desktop'])); +const DEFAULT_PSI_CATEGORIES = Object.freeze(['PERFORMANCE', 'ACCESSIBILITY', 'BEST_PRACTICES', 'SEO']); /** @import { pagespeedonline_v5 } from '@googleapis/pagespeedonline' */ @@ -20,10 +25,22 @@ const DEFAULT_PSI_CATEGORIES = ['PERFORMANCE', 'ACCESSIBILITY', 'BEST_PRACTICES' * @typedef {{ url: string, strategy: PsiStrategy, outputPath: string|null, error: string|null }} PsiBatchResult */ +/** + * @typedef {Object} PsiBatchOptions + * @property {number} [concurrency] + * @property {number} [delayMs] + * @property {readonly PsiStrategy[]} [strategies] + * @property {(completed: number, total: number, url: string, error: string|null) => void} [onProgress] + */ + +/** + * @typedef {PsiBatchOptions & { clean?: boolean }} PsiWriteBatchOptions + */ + /** * @param {string} url * @param {string} apiKey - * @param {string[]} [categories] + * @param {readonly string[]} [categories] * @param {PsiStrategy} [strategy] * @returns {Promise} */ @@ -73,8 +90,8 @@ function writePsiReport(url, strategy, data, clean) { * and writes each result to disk. * @param {string} url - The URL to audit. * @param {string} apiKey - PageSpeed Insights API key. - * @param {string[]} [categories] - Lighthouse categories to evaluate. - * @param {{ clean?: boolean, strategies?: PsiStrategy[] }} [options] + * @param {readonly string[]} [categories] - Lighthouse categories to evaluate. + * @param {{ clean?: boolean, strategies?: readonly PsiStrategy[] }} [options] * @returns {Promise} Output file paths, one per strategy in the order requested. */ async function runPsi(url, apiKey, categories = DEFAULT_PSI_CATEGORIES, { clean = false, strategies = DEFAULT_PSI_STRATEGIES } = {}) { @@ -91,8 +108,8 @@ async function runPsi(url, apiKey, categories = DEFAULT_PSI_CATEGORIES, { clean /** * @param {string[]} urls * @param {string} apiKey - * @param {string[]} categories - * @param {{ concurrency?: number, delayMs?: number, strategies?: PsiStrategy[], onProgress?: (completed: number, total: number, url: string, error: string|null) => void }} [options] + * @param {readonly string[]} categories + * @param {PsiBatchOptions} [options] * @returns {Promise>} */ async function runPsiAuditBatch(urls, apiKey, categories, { concurrency = 5, delayMs = 0, strategies = DEFAULT_PSI_STRATEGIES, onProgress } = {}) { @@ -122,8 +139,8 @@ async function runPsiAuditBatch(urls, apiKey, categories, { concurrency = 5, del /** * @param {string[]} urls * @param {string} apiKey - * @param {string[]} categories - * @param {{ concurrency?: number, delayMs?: number, clean?: boolean, strategies?: PsiStrategy[], onProgress?: (completed: number, total: number, url: string, error: string|null) => void }} [options] + * @param {readonly string[]} categories + * @param {PsiWriteBatchOptions} [options] * @returns {Promise} */ async function runPsiBatch(urls, apiKey, categories, { concurrency = 5, delayMs = 0, clean = false, strategies = DEFAULT_PSI_STRATEGIES, onProgress } = {}) { diff --git a/type-tests/frozen-constants.ts b/type-tests/frozen-constants.ts new file mode 100644 index 0000000..fbdd4b1 --- /dev/null +++ b/type-tests/frozen-constants.ts @@ -0,0 +1,50 @@ +// Every exported constant that is ALSO a default argument must be readonly, must carry its +// element type, and must still be passable to the option it is the default for. +// +// Freezing alone is not enough — that was the lesson from the CrUX pair. Declared `string[]`, +// the constant could never be passed back into `formFactors`, so the freeze only changed the +// error code from TS2322 to TS4104. Each constant below is therefore asserted three ways: +// pass it in, spread it to extend, and fail to mutate it. + +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'; + +// --- PSI strategies ------------------------------------------------------------------------- +export const psiDefault = runPsi('https://example.com', 'KEY', undefined, { + strategies: DEFAULT_PSI_STRATEGIES, +}); +export const psiBatch = runPsiBatch(['https://example.com'], 'KEY', ['PERFORMANCE'], { + strategies: PSI_STRATEGIES, +}); +export const psiExtended = runPsi('https://example.com', 'KEY', undefined, { + strategies: [...DEFAULT_PSI_STRATEGIES], +}); +// @ts-expect-error DEFAULT_PSI_STRATEGIES is frozen and published as readonly +DEFAULT_PSI_STRATEGIES.push('mobile'); +// @ts-expect-error PSI_STRATEGIES is frozen and published as readonly +PSI_STRATEGIES.push('mobile'); + +// --- CrUX form factors (fixed earlier; asserted here alongside the rest) --------------------- +export const cruxDefault = runCrux('https://example.com', 'KEY', { + formFactors: DEFAULT_CRUX_FORM_FACTORS, +}); +// @ts-expect-error DEFAULT_CRUX_FORM_FACTORS is frozen and published as readonly +DEFAULT_CRUX_FORM_FACTORS.push('tablet'); + +// --- lab: skipped audits and Chrome flags --------------------------------------------------- +export const config = buildLighthouseConfig({ skipAudits: [...DEFAULT_SKIP_AUDITS] }, {}); +// @ts-expect-error DEFAULT_SKIP_AUDITS is frozen and published as readonly +DEFAULT_SKIP_AUDITS.push('uses-http2'); + +// chrome-launcher types chromeFlags as a mutable Array, so a consumer must spread. +// This is the documented shape of that workaround, and it has to keep compiling. +export const launchFlags: string[] = [...CHROME_FLAGS]; +// @ts-expect-error CHROME_FLAGS is frozen and published as readonly +CHROME_FLAGS.push('--headless=new'); + +// --- lab categories ------------------------------------------------------------------------- +export const categories: string[] = [...LAB_CATEGORIES]; +// @ts-expect-error LAB_CATEGORIES is frozen and published as readonly +LAB_CATEGORIES.push('performance'); diff --git a/types/lib/lab.d.ts b/types/lib/lab.d.ts index e3ee0be..6cb7414 100644 --- a/types/lib/lab.d.ts +++ b/types/lib/lab.d.ts @@ -368,5 +368,5 @@ export function writeLabResult(outputPath: any, data: any, labOptions?: {}): voi * @returns {string} the summary file path */ export function writeRunSummary(url: string, profile: string, summary: import("./variance").RunSummary, firstRunPath?: string): string; -export const CHROME_FLAGS: string[]; -export const DEFAULT_SKIP_AUDITS: string[]; +export const CHROME_FLAGS: readonly string[]; +export const DEFAULT_SKIP_AUDITS: readonly string[]; diff --git a/types/lib/profiles.d.ts b/types/lib/profiles.d.ts index 906a02f..7b44fec 100644 --- a/types/lib/profiles.d.ts +++ b/types/lib/profiles.d.ts @@ -123,7 +123,7 @@ export const DEVICE_PRESETS: { label: string; }; }; -export const LAB_CATEGORIES: string[]; +export const LAB_CATEGORIES: readonly string[]; export const DEVTOOLS_RTT_ADJUSTMENT_FACTOR: 3.75; export const DEVTOOLS_THROUGHPUT_ADJUSTMENT_FACTOR: 0.9; export const MOBILE_UA: "Mozilla/5.0 (Linux; Android 11; moto g power (2022)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Mobile Safari/537.36"; diff --git a/types/lib/psi.d.ts b/types/lib/psi.d.ts index dbe2170..4a36c65 100644 --- a/types/lib/psi.d.ts +++ b/types/lib/psi.d.ts @@ -11,33 +11,36 @@ export type PsiBatchResult = { outputPath: string | null; error: string | null; }; +export type PsiBatchOptions = { + concurrency?: number | undefined; + delayMs?: number | undefined; + strategies?: readonly PsiStrategy[] | undefined; + onProgress?: ((completed: number, total: number, url: string, error: string | null) => void) | undefined; +}; +export type PsiWriteBatchOptions = PsiBatchOptions & { + clean?: boolean; +}; /** * Runs PageSpeed Insights audits for a single URL across one or more strategies * and writes each result to disk. * @param {string} url - The URL to audit. * @param {string} apiKey - PageSpeed Insights API key. - * @param {string[]} [categories] - Lighthouse categories to evaluate. - * @param {{ clean?: boolean, strategies?: PsiStrategy[] }} [options] + * @param {readonly string[]} [categories] - Lighthouse categories to evaluate. + * @param {{ clean?: boolean, strategies?: readonly PsiStrategy[] }} [options] * @returns {Promise} Output file paths, one per strategy in the order requested. */ -export function runPsi(url: string, apiKey: string, categories?: string[], { clean, strategies }?: { +export function runPsi(url: string, apiKey: string, categories?: readonly string[], { clean, strategies }?: { clean?: boolean; - strategies?: PsiStrategy[]; + strategies?: readonly PsiStrategy[]; }): Promise; /** * @param {string[]} urls * @param {string} apiKey - * @param {string[]} categories - * @param {{ concurrency?: number, delayMs?: number, clean?: boolean, strategies?: PsiStrategy[], onProgress?: (completed: number, total: number, url: string, error: string|null) => void }} [options] + * @param {readonly string[]} categories + * @param {PsiWriteBatchOptions} [options] * @returns {Promise} */ -export function runPsiBatch(urls: string[], apiKey: string, categories: string[], { concurrency, delayMs, clean, strategies, onProgress }?: { - concurrency?: number; - delayMs?: number; - clean?: boolean; - strategies?: PsiStrategy[]; - onProgress?: (completed: number, total: number, url: string, error: string | null) => void; -}): Promise; +export function runPsiBatch(urls: string[], apiKey: string, categories: readonly string[], { concurrency, delayMs, clean, strategies, onProgress }?: PsiWriteBatchOptions): Promise; /** @import { pagespeedonline_v5 } from '@googleapis/pagespeedonline' */ /** * @typedef {'mobile'|'desktop'} PsiStrategy @@ -46,33 +49,38 @@ export function runPsiBatch(urls: string[], apiKey: string, categories: string[] * @typedef {{ url: string, strategy: PsiStrategy }} PsiWorkItem * @typedef {{ url: string, strategy: PsiStrategy, outputPath: string|null, error: string|null }} PsiBatchResult */ +/** + * @typedef {Object} PsiBatchOptions + * @property {number} [concurrency] + * @property {number} [delayMs] + * @property {readonly PsiStrategy[]} [strategies] + * @property {(completed: number, total: number, url: string, error: string|null) => void} [onProgress] + */ +/** + * @typedef {PsiBatchOptions & { clean?: boolean }} PsiWriteBatchOptions + */ /** * @param {string} url * @param {string} apiKey - * @param {string[]} [categories] + * @param {readonly string[]} [categories] * @param {PsiStrategy} [strategy] * @returns {Promise} */ -export function runPsiAudit(url: string, apiKey: string, categories?: string[], strategy?: PsiStrategy): Promise; +export function runPsiAudit(url: string, apiKey: string, categories?: readonly string[], strategy?: PsiStrategy): Promise; /** * @param {string[]} urls * @param {string} apiKey - * @param {string[]} categories - * @param {{ concurrency?: number, delayMs?: number, strategies?: PsiStrategy[], onProgress?: (completed: number, total: number, url: string, error: string|null) => void }} [options] + * @param {readonly string[]} categories + * @param {PsiBatchOptions} [options] * @returns {Promise>} */ -export function runPsiAuditBatch(urls: string[], apiKey: string, categories: string[], { concurrency, delayMs, strategies, onProgress }?: { - concurrency?: number; - delayMs?: number; - strategies?: PsiStrategy[]; - onProgress?: (completed: number, total: number, url: string, error: string | null) => void; -}): Promise>; export const PSI_MAX_REQUESTS_PER_SECOND: 4; -export const PSI_STRATEGIES: string[]; -export const DEFAULT_PSI_STRATEGIES: string[]; +export const PSI_STRATEGIES: readonly PsiStrategy[]; +export const DEFAULT_PSI_STRATEGIES: readonly PsiStrategy[]; import type { pagespeedonline_v5 } from '@googleapis/pagespeedonline';