Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions lib/lab.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>, 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)
Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion lib/links.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
4 changes: 3 additions & 1 deletion lib/profiles.js
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
37 changes: 27 additions & 10 deletions lib/psi.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' */

Expand All @@ -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<PsiResponse>}
*/
Expand Down Expand Up @@ -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<string[]>} 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 } = {}) {
Expand All @@ -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<Array<{ url: string, strategy: PsiStrategy, data: PsiResponse|null, error: string|null }>>}
*/
async function runPsiAuditBatch(urls, apiKey, categories, { concurrency = 5, delayMs = 0, strategies = DEFAULT_PSI_STRATEGIES, onProgress } = {}) {
Expand Down Expand Up @@ -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<PsiBatchResult[]>}
*/
async function runPsiBatch(urls, apiKey, categories, { concurrency = 5, delayMs = 0, clean = false, strategies = DEFAULT_PSI_STRATEGIES, onProgress } = {}) {
Expand Down
50 changes: 50 additions & 0 deletions type-tests/frozen-constants.ts
Original file line number Diff line number Diff line change
@@ -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<string>, 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');
4 changes: 2 additions & 2 deletions types/lib/lab.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
2 changes: 1 addition & 1 deletion types/lib/profiles.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
58 changes: 33 additions & 25 deletions types/lib/psi.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string[]>} 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<string[]>;
/**
* @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<PsiBatchResult[]>}
*/
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<PsiBatchResult[]>;
export function runPsiBatch(urls: string[], apiKey: string, categories: readonly string[], { concurrency, delayMs, clean, strategies, onProgress }?: PsiWriteBatchOptions): Promise<PsiBatchResult[]>;
/** @import { pagespeedonline_v5 } from '@googleapis/pagespeedonline' */
/**
* @typedef {'mobile'|'desktop'} PsiStrategy
Expand All @@ -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<PsiResponse>}
*/
export function runPsiAudit(url: string, apiKey: string, categories?: string[], strategy?: PsiStrategy): Promise<PsiResponse>;
export function runPsiAudit(url: string, apiKey: string, categories?: readonly string[], strategy?: PsiStrategy): Promise<PsiResponse>;
/**
* @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<Array<{ url: string, strategy: PsiStrategy, data: PsiResponse|null, error: string|null }>>}
*/
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<Array<{
export function runPsiAuditBatch(urls: string[], apiKey: string, categories: readonly string[], { concurrency, delayMs, strategies, onProgress }?: PsiBatchOptions): Promise<Array<{
url: string;
strategy: PsiStrategy;
data: PsiResponse | null;
error: string | null;
}>>;
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';
Loading