From 05501eae64cdcc287d4b41b66537fbec14d6c9a2 Mon Sep 17 00:00:00 2001 From: hugoer Date: Thu, 3 Sep 2026 13:57:04 +0200 Subject: [PATCH 01/11] fix(lab): make the published types accept the calls the CLI actually makes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three gaps in the shipped declarations, each confirmed against a consumer project compiled under node16 resolution. LabPlanOptions declared only the plan-level controls, so the published type rejected every per-run option runLabPlan forwards — TS2353 on skipAudits, and the same for categories, blockedUrlPatterns, stripJsonProps, clean and silent. Those are exactly the options bin/web-perf.js passes. The controls are now LabPlanControls, the forwarded options are LabWriteOptions (LabAuditOptions plus clean and the internally-set runNumber), and LabPlanOptions is their intersection. LabReport omitted environment, runtimeError and configSettings. stripJsonProps drops only i18n and timing, so all three survive by default; buildRunSummary reads environment.benchmarkIndex, runLabPlan reads runtimeError, cleanLabReport reads configSettings.formFactor, and the README tells consumers the report carries them. Reading any of the three was TS2339. LabPlanHooks.onSummary typed its argument as bare `object`, so the CLI's own `summary.stability.warnings` would not compile for a consumer. It is now import('./variance').RunSummary, which this file already referenced in writeRunSummary. --- lib/lab.js | 31 ++++++++++++++++-- types/lib/lab.d.ts | 80 ++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 102 insertions(+), 9 deletions(-) diff --git a/lib/lab.js b/lib/lab.js index 80c6137..a62cd5e 100644 --- a/lib/lab.js +++ b/lib/lab.js @@ -62,6 +62,15 @@ const CHROME_FLAGS = [ * declaring it required promised consumers a field the default path deletes. * @property {Record} categories * @property {Record} audits + * @property {{ benchmarkIndex?: number, hostUserAgent?: string, networkUserAgent?: string }} [environment] - + * survives stripJsonProps, which drops only `i18n` and `timing`. buildRunSummary reads + * `environment.benchmarkIndex` from it. + * @property {{ code: string, message?: string }} [runtimeError] - present when Lighthouse + * resolved rather than threw: the page failed to load and the report carries no usable + * metrics. runLabPlan treats a report with this set as a failed run. + * @property {{ formFactor?: 'desktop'|'mobile', [key: string]: unknown }} [configSettings] - + * also survives stripJsonProps. cleanLabReport hoists formFactor out of it, because + * Lighthouse 13 moved the field here from the report root. */ /** @@ -86,9 +95,9 @@ const CHROME_FLAGS = [ * @property {(ctx: LabPlanContext) => void} [onRunStart] * @property {(ctx: LabPlanContext & { outputPath: string, report: LabReport }) => void} [onRunComplete] * @property {(ctx: LabPlanContext & { error: string, outputPath?: string }) => void} [onRunError] - * @property {(ctx: { url: string, profile: string, summary: object, summaryPath: string }) => void} [onSummary] + * @property {(ctx: { url: string, profile: string, summary: import('./variance').RunSummary, summaryPath: string }) => void} [onSummary] * - * @typedef {Object} LabPlanOptions + * @typedef {Object} LabPlanControls * @property {boolean} [continueOnError=false] - collect failures instead of aborting the plan * @property {boolean} [reuseBrowser=false] - share one Chrome across every run. Faster, but * Lighthouse does not clear DNS caches or socket pools between runs, so later runs start @@ -99,6 +108,22 @@ const CHROME_FLAGS = [ * @property {(opts: object) => Promise<{ port: number, kill: () => Promise }>} [_launch] - injectable Chrome launcher (tests) */ +/** + * What runLabToDisk takes: the audit options, plus the flag that writes an AI-friendly copy + * beside the raw report. `runNumber` is set by runLabPlan itself, not by callers. + * @typedef {LabAuditOptions & { clean?: boolean, runNumber?: number }} LabWriteOptions + */ + +/** + * Plan-level controls plus the per-run options forwarded to every audit. + * + * runLabPlan destructures the controls and spreads the rest into each runLabToDisk call, so + * the two halves genuinely are one options object to a caller. Declaring only the controls + * made the published type reject `skipAudits`, `categories`, `blockedUrlPatterns`, + * `stripJsonProps`, `clean` and `silent` — every option bin/web-perf.js actually passes. + * @typedef {LabPlanControls & LabWriteOptions} LabPlanOptions + */ + function buildLighthouseConfig(labOptions, profileSettings = {}) { const rawSkipAudits = labOptions.skipAudits || DEFAULT_SKIP_AUDITS; const disableFullPageScreenshot = rawSkipAudits.includes('full-page-screenshot'); @@ -194,7 +219,7 @@ function buildRunSuffix(labOptions) { * `runLab` wraps this to keep its published `Promise` signature; `runLabPlan` uses * it directly so summaries can read scores without re-parsing the file it just wrote. * @param {string} url - * @param {object} [labOptions] + * @param {LabWriteOptions} [labOptions] * @returns {Promise<{ outputPath: string, data: LabReport }>} */ async function runLabToDisk(url, labOptions = {}) { diff --git a/types/lib/lab.d.ts b/types/lib/lab.d.ts index c59ae7b..6752ad2 100644 --- a/types/lib/lab.d.ts +++ b/types/lib/lab.d.ts @@ -39,6 +39,34 @@ export type LabReport = { } | undefined; categories: Record; audits: Record; + /** + * - + * survives stripJsonProps, which drops only `i18n` and `timing`. buildRunSummary reads + * `environment.benchmarkIndex` from it. + */ + environment?: { + benchmarkIndex?: number; + hostUserAgent?: string; + networkUserAgent?: string; + } | undefined; + /** + * - present when Lighthouse + * resolved rather than threw: the page failed to load and the report carries no usable + * metrics. runLabPlan treats a report with this set as a failed run. + */ + runtimeError?: { + code: string; + message?: string; + } | undefined; + /** + * - + * also survives stripJsonProps. cleanLabReport hoists formFactor out of it, because + * Lighthouse 13 moved the field here from the report root. + */ + configSettings?: { + [key: string]: unknown; + formFactor?: "desktop" | "mobile"; + } | undefined; }; export type LabRun = { profile?: string; @@ -91,11 +119,11 @@ export type LabPlanHooks = { onSummary?: ((ctx: { url: string; profile: string; - summary: object; + summary: import("./variance").RunSummary; summaryPath: string; }) => void) | undefined; }; -export type LabPlanOptions = { +export type LabPlanControls = { /** * - collect failures instead of aborting the plan */ @@ -126,6 +154,23 @@ export type LabPlanOptions = { kill: () => Promise; }>) | undefined; }; +/** + * What runLabToDisk takes: the audit options, plus the flag that writes an AI-friendly copy + * beside the raw report. `runNumber` is set by runLabPlan itself, not by callers. + */ +export type LabWriteOptions = LabAuditOptions & { + clean?: boolean; + runNumber?: number; +}; +/** + * Plan-level controls plus the per-run options forwarded to every audit. + * + * runLabPlan destructures the controls and spreads the rest into each runLabToDisk call, so + * the two halves genuinely are one options object to a caller. Declaring only the controls + * made the published type reject `skipAudits`, `categories`, `blockedUrlPatterns`, + * `stripJsonProps`, `clean` and `silent` — every option bin/web-perf.js actually passes. + */ +export type LabPlanOptions = LabPlanControls & LabWriteOptions; export type LabAuditOptions = { /** * - attach to an already-running Chrome instead of launching one @@ -176,10 +221,10 @@ export function runLabAudit(url: string, labOptions?: LabAuditOptions): Promise< * `runLab` wraps this to keep its published `Promise` signature; `runLabPlan` uses * it directly so summaries can read scores without re-parsing the file it just wrote. * @param {string} url - * @param {object} [labOptions] + * @param {LabWriteOptions} [labOptions] * @returns {Promise<{ outputPath: string, data: LabReport }>} */ -export function runLabToDisk(url: string, labOptions?: object): Promise<{ +export function runLabToDisk(url: string, labOptions?: LabWriteOptions): Promise<{ outputPath: string; data: LabReport; }>; @@ -217,6 +262,15 @@ export function runLabToDisk(url: string, labOptions?: object): Promise<{ * declaring it required promised consumers a field the default path deletes. * @property {Record} categories * @property {Record} audits + * @property {{ benchmarkIndex?: number, hostUserAgent?: string, networkUserAgent?: string }} [environment] - + * survives stripJsonProps, which drops only `i18n` and `timing`. buildRunSummary reads + * `environment.benchmarkIndex` from it. + * @property {{ code: string, message?: string }} [runtimeError] - present when Lighthouse + * resolved rather than threw: the page failed to load and the report carries no usable + * metrics. runLabPlan treats a report with this set as a failed run. + * @property {{ formFactor?: 'desktop'|'mobile', [key: string]: unknown }} [configSettings] - + * also survives stripJsonProps. cleanLabReport hoists formFactor out of it, because + * Lighthouse 13 moved the field here from the report root. */ /** * @typedef {{ profile?: string, network?: string, device?: string }} LabRun @@ -240,9 +294,9 @@ export function runLabToDisk(url: string, labOptions?: object): Promise<{ * @property {(ctx: LabPlanContext) => void} [onRunStart] * @property {(ctx: LabPlanContext & { outputPath: string, report: LabReport }) => void} [onRunComplete] * @property {(ctx: LabPlanContext & { error: string, outputPath?: string }) => void} [onRunError] - * @property {(ctx: { url: string, profile: string, summary: object, summaryPath: string }) => void} [onSummary] + * @property {(ctx: { url: string, profile: string, summary: import('./variance').RunSummary, summaryPath: string }) => void} [onSummary] * - * @typedef {Object} LabPlanOptions + * @typedef {Object} LabPlanControls * @property {boolean} [continueOnError=false] - collect failures instead of aborting the plan * @property {boolean} [reuseBrowser=false] - share one Chrome across every run. Faster, but * Lighthouse does not clear DNS caches or socket pools between runs, so later runs start @@ -252,6 +306,20 @@ export function runLabToDisk(url: string, labOptions?: object): Promise<{ * @property {(url: string, opts: object) => Promise<{ outputPath: string, data: LabReport }>} [_runLab] - injectable runner (tests) * @property {(opts: object) => Promise<{ port: number, kill: () => Promise }>} [_launch] - injectable Chrome launcher (tests) */ +/** + * What runLabToDisk takes: the audit options, plus the flag that writes an AI-friendly copy + * beside the raw report. `runNumber` is set by runLabPlan itself, not by callers. + * @typedef {LabAuditOptions & { clean?: boolean, runNumber?: number }} LabWriteOptions + */ +/** + * Plan-level controls plus the per-run options forwarded to every audit. + * + * runLabPlan destructures the controls and spreads the rest into each runLabToDisk call, so + * the two halves genuinely are one options object to a caller. Declaring only the controls + * made the published type reject `skipAudits`, `categories`, `blockedUrlPatterns`, + * `stripJsonProps`, `clean` and `silent` — every option bin/web-perf.js actually passes. + * @typedef {LabPlanControls & LabWriteOptions} LabPlanOptions + */ export function buildLighthouseConfig(labOptions: any, profileSettings?: {}): { extends: string; settings: any; From fb95202a249bbddea46057ca130fcea7cace08f8 Mon Sep 17 00:00:00 2001 From: hugoer Date: Thu, 3 Sep 2026 13:57:14 +0200 Subject: [PATCH 02/11] fix(crux): freeze the exported form-factor constants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CRUX_FORM_FACTORS and DEFAULT_CRUX_FORM_FACTORS are exported from both crux and crux-history as the same array instance, and DEFAULT_CRUX_FORM_FACTORS is also the default parameter value inside the client. A consumer calling push on it would have added a form factor to every later runCrux and runCruxHistory call in the process — silently increasing requests per URL against a 25,000/day quota. lib/prompts.js already spread the array defensively, so the hazard was understood but never closed. Both are now frozen, with tests asserting the freeze, the shared identity that made it matter, that a push throws, and that spreading to extend still works. Type-level change worth naming: DEFAULT_CRUX_FORM_FACTORS is now `readonly string[]` rather than `string[]`, so a consumer assigning it to a mutable string[] will need to spread. That is the type telling the truth about what the value has always been. --- lib/crux-client.js | 8 ++++++-- lib/crux-client.test.js | 26 ++++++++++++++++++++++++++ types/lib/crux-client.d.ts | 2 +- 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/lib/crux-client.js b/lib/crux-client.js index d0ce491..055005e 100644 --- a/lib/crux-client.js +++ b/lib/crux-client.js @@ -5,8 +5,12 @@ const { } = require('./utils'); const CRUX_MAX_REQUESTS_PER_SECOND = 2.5; -const CRUX_FORM_FACTORS = /** @type {const} */ (['phone', 'desktop', 'tablet']); -const DEFAULT_CRUX_FORM_FACTORS = ['phone', 'desktop']; +// Frozen because both are exported from crux and crux-history as the same instance, and +// DEFAULT_CRUX_FORM_FACTORS is also the default parameter value inside the client: a consumer +// pushing to it would silently add a form factor to every later call in the process, against +// a metered quota. Callers who need to extend them spread first, as lib/prompts.js does. +const CRUX_FORM_FACTORS = Object.freeze(/** @type {const} */ (['phone', 'desktop', 'tablet'])); +const DEFAULT_CRUX_FORM_FACTORS = Object.freeze(['phone', 'desktop']); /** * @typedef {'phone'|'desktop'|'tablet'} CruxFormFactor diff --git a/lib/crux-client.test.js b/lib/crux-client.test.js index 740c0fd..a1193dd 100644 --- a/lib/crux-client.test.js +++ b/lib/crux-client.test.js @@ -180,3 +180,29 @@ describe('crux and crux-history are wired to different endpoints', () => { await expect(runCruxHistoryAudit('https://example.com', 'KEY')).rejects.toThrow('No CrUX history data found'); }); }); + +describe('exported form-factor constants are immutable', () => { + const crux = require('./crux'); + const cruxHistory = require('./crux-history'); + + it('freezes both constants', () => { + expect(Object.isFrozen(crux.CRUX_FORM_FACTORS)).toBe(true); + expect(Object.isFrozen(crux.DEFAULT_CRUX_FORM_FACTORS)).toBe(true); + }); + + // They are deliberately one instance shared by both subpaths, which is exactly why a + // consumer mutating one would have changed the default for the other. + it('shares one instance across crux and crux-history', () => { + expect(crux.DEFAULT_CRUX_FORM_FACTORS).toBe(cruxHistory.DEFAULT_CRUX_FORM_FACTORS); + expect(crux.CRUX_FORM_FACTORS).toBe(cruxHistory.CRUX_FORM_FACTORS); + }); + + it('rejects a push that would add a form factor to every later call', () => { + expect(() => crux.DEFAULT_CRUX_FORM_FACTORS.push('tablet')).toThrow(TypeError); + expect(crux.DEFAULT_CRUX_FORM_FACTORS).toEqual(['phone', 'desktop']); + }); + + it('still allows callers to extend a copy', () => { + expect([...crux.DEFAULT_CRUX_FORM_FACTORS, 'tablet']).toEqual(['phone', 'desktop', 'tablet']); + }); +}); diff --git a/types/lib/crux-client.d.ts b/types/lib/crux-client.d.ts index 7710ee4..70c6bbc 100644 --- a/types/lib/crux-client.d.ts +++ b/types/lib/crux-client.d.ts @@ -122,4 +122,4 @@ export function createCruxClient({ endpoint, command, dataLabel, periodKey, maxR }; export const CRUX_MAX_REQUESTS_PER_SECOND: 2.5; export const CRUX_FORM_FACTORS: readonly ["phone", "desktop", "tablet"]; -export const DEFAULT_CRUX_FORM_FACTORS: string[]; +export const DEFAULT_CRUX_FORM_FACTORS: readonly string[]; From 72c48c8ad4dd680c0c1e1600e3b3207369be2077 Mon Sep 17 00:00:00 2001 From: hugoer Date: Thu, 3 Sep 2026 13:57:24 +0200 Subject: [PATCH 03/11] test: guard the published declarations with a consumer type-check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit npm test exercises the implementation; nothing checked that the shipped .d.ts files describe it. All four defects in this issue reached a release that way, and the crux/crux-history option types regressed the same way in #15. type-tests/consumer.ts imports the package by name, so it resolves through package.json "exports" exactly as a consumer's build would, and exercises the surface the README documents: runLabPlan with every CLI option and all four hooks, the LabReport fields that survive stripJsonProps, buildRunSummary, both crux record shapes, and the readonly defaults. It is type-checked only — never executed, never published (top-level, so outside the "files" list). Run with `npm run check-types`. Confirmed load-bearing by reverting each fix in turn and re-running the guard: LabPlanOptions loses the run options -> caught onSummary reverts to bare object -> caught LabReport loses environment -> caught --- package.json | 3 +- type-tests/consumer.ts | 78 ++++++++++++++++++++++++++++++++++++++++ type-tests/tsconfig.json | 8 +++++ 3 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 type-tests/consumer.ts create mode 100644 type-tests/tsconfig.json diff --git a/package.json b/package.json index b573b9b..2b8c299 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,8 @@ "start": "node bin/web-perf.js", "lint": "eslint . --fix", "test": "vitest run", - "generate-types": "tsc --project tsconfig.types.json" + "generate-types": "tsc --project tsconfig.types.json", + "check-types": "tsc --project type-tests/tsconfig.json" }, "dependencies": { "chalk": "^6.0.0", diff --git a/type-tests/consumer.ts b/type-tests/consumer.ts new file mode 100644 index 0000000..150d786 --- /dev/null +++ b/type-tests/consumer.ts @@ -0,0 +1,78 @@ +// Type-level regression tests for the PUBLISHED declarations in types/. +// +// `npm test` runs the implementation; nothing checked that the .d.ts files describe it. Three +// gaps shipped that way: LabPlanOptions rejected every option the CLI passes, LabReport +// omitted environment/runtimeError/configSettings, and onSummary's argument was typed `object`. +// +// This file imports the package by name, so it resolves through package.json "exports" exactly +// as a consumer's would. It is type-checked only — never executed, never published. +// Run with: npm run check-types + +import { runLabPlan, runLabAudit } from '@hugoer/web-perf-cli/lab'; +import { runCruxAudit, runCruxBatch, DEFAULT_CRUX_FORM_FACTORS } from '@hugoer/web-perf-cli/crux'; +import { runCruxHistoryAudit } from '@hugoer/web-perf-cli/crux-history'; +import { buildRunSummary } from '@hugoer/web-perf-cli/variance'; +import type { LabReport, LabPlanResult } from '@hugoer/web-perf-cli/lab'; +import type { CruxReport } from '@hugoer/web-perf-cli/crux'; +import type { CruxHistoryReport } from '@hugoer/web-perf-cli/crux-history'; + +// --- runLabPlan accepts every option bin/web-perf.js passes ------------------------------- +export const plan: Promise = runLabPlan( + ['https://example.com'], + [{ profile: 'low' }], + { + skipAudits: ['uses-http2'], + blockedUrlPatterns: ['*.example-ads.com'], + categories: ['performance'], + stripJsonProps: true, + clean: true, + silent: false, + continueOnError: true, + reuseBrowser: false, + repeats: 3, + }, + { + onRunStart: ({ url, runIndex, totalRuns }) => `${url}${runIndex}${totalRuns}`, + onRunComplete: ({ outputPath, report }) => `${outputPath}${report.finalUrl}`, + onRunError: ({ error }) => error, + // onSummary must expose RunSummary, not a bare object: the CLI dereferences this. + onSummary: ({ summary }) => summary.stability.warnings.map((w: string) => w), + }, +); + +// --- LabReport declares what survives stripJsonProps --------------------------------------- +export function readsReport(r: LabReport) { + const bench: number | undefined = r.environment?.benchmarkIndex; + const failed: string | undefined = r.runtimeError?.code; + const ff = r.configSettings?.formFactor; + // timing is optional: stripJsonProps removes it by default + const total: number | undefined = r.timing?.total; + return [bench, failed, ff, total, r.categories, r.audits]; +} + +// --- variance consumes reports collected by any means -------------------------------------- +export const summary = buildRunSummary( + [{ report: {} as LabReport, outputPath: 'a.json' }], + { url: 'https://example.com', profile: 'low' }, +); +export const median: number | null = summary.median; + +// --- crux / crux-history keep distinct record shapes --------------------------------------- +export async function cruxShapes(): Promise<[CruxReport, CruxHistoryReport]> { + const single = await runCruxAudit('https://example.com', 'KEY', { scope: 'origin', formFactor: 'phone' }); + const history = await runCruxHistoryAudit('https://example.com', 'KEY', { scope: 'page' }); + return [single, history]; +} + +export const batch = runCruxBatch(['https://example.com'], 'KEY', { + scope: 'page', + concurrency: 2, + delayMs: 0, + formFactors: ['phone', 'desktop'], + onProgress: (done, total, url, error, statusCode) => `${done}/${total}${url}${error}${statusCode}`, +}); + +// --- the exported defaults are readonly ---------------------------------------------------- +export const extended: string[] = [...DEFAULT_CRUX_FORM_FACTORS, 'tablet']; + +export const audit = runLabAudit('https://example.com', { profile: 'low', silent: true }); diff --git a/type-tests/tsconfig.json b/type-tests/tsconfig.json new file mode 100644 index 0000000..c0979f6 --- /dev/null +++ b/type-tests/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "noEmit": true, "strict": true, + "module": "node16", "moduleResolution": "node16", + "target": "ES2022", "skipLibCheck": true, "types": [] + }, + "include": ["*.ts"] +} From 30eff6bf115a33acff79f1f4cc1bafde399cedee Mon Sep 17 00:00:00 2001 From: hugoer Date: Thu, 3 Sep 2026 13:57:49 +0200 Subject: [PATCH 04/11] docs: add check-types to the development checklist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The checklist ended at generate-types, which emits the declarations but never checks them. check-types is now the fourth step, and runs last because it compiles against what generate-types just wrote. Extends the JSDoc rule with the reason this issue existed: npm test exercises the implementation, not the .d.ts, so a published type can be wrong while all 565 tests pass — LabPlanOptions rejected every option the CLI itself passes. Adding an option, a return field or a hook argument now means extending type-tests/consumer.ts, or the guard misses it. --- CLAUDE.md | 7 +++++++ README.md | 6 ++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6ea008b..2e7df0a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -185,12 +185,19 @@ Run these in order at the end of every task, without exception: npm run lint # must pass before running tests npm test # all tests must pass npm run generate-types # regenerate types after any function signature change +npm run check-types # type-check a consumer against the regenerated .d.ts ``` +`check-types` runs last because it reads what `generate-types` just emitted. It compiles +`type-tests/consumer.ts`, which imports the package by name and so resolves through +`package.json` "exports" exactly as a consumer's build would. + ### Rules **JSDoc** — Any change to a function's parameters or return value requires updating its `@param` / `@returns` JSDoc. The generated `.d.ts` is the source of truth for consumers; stale types are bugs. +`npm test` does not check the `.d.ts` files — it exercises the implementation. `npm run check-types` is what checks them, and a published type can be wrong while every test passes: `LabPlanOptions` once rejected every option the CLI itself passes. When a change adds an option, a return field, or a hook argument, extend `type-tests/consumer.ts` to use it, or the guard will not cover it. + **New lib modules** — two steps, and only the first is automatic. 1. `tsconfig.types.json` → `include` array. **Always.** `generate-types` only emits a `.d.ts` for what is listed, and a private module still needs one when another module's published types reference it. diff --git a/README.md b/README.md index 6957df7..73a62c0 100644 --- a/README.md +++ b/README.md @@ -570,9 +570,11 @@ node bin/web-perf.js lab https://example.com | `npm run lint` | Lint and auto-fix with ESLint | | `npm test` | Run all tests (vitest) | | `npm run generate-types` | Regenerate `types/lib/*.d.ts` from JSDoc annotations | +| `npm run check-types` | Type-check a sample consumer against the generated declarations | -Run them in that order at the end of every change — `lint` must pass before `test`, and -`generate-types` last so the regenerated `.d.ts` reflects the final JSDoc. +Run them in that order at the end of every change — `lint` must pass before `test`, +`generate-types` after that so the regenerated `.d.ts` reflects the final JSDoc, and +`check-types` last, since it compiles a sample consumer against what `generate-types` emitted. ### Regenerating types From 58cace78c78a3d39fa85862739b96fc2017aaa27 Mon Sep 17 00:00:00 2001 From: hugoer Date: Thu, 3 Sep 2026 14:21:51 +0200 Subject: [PATCH 05/11] fix(crux): make the exported form-factor constants usable from TypeScript MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DEFAULT_CRUX_FORM_FACTORS was declared `string[]`, and `string` is not assignable to CruxFormFactor, so passing the constant to the `formFactors` option it is the default for has never compiled — `runCrux(url, key, { formFactors: DEFAULT_CRUX_FORM_FACTORS })` failed with TS2322 on main and still failed after the freeze, only with TS4104 instead. Worse, the workaround the freeze commit prescribed in both the code comment and the commit message — "spread first, as lib/prompts.js does" — did not compile either, because a spread of a `string[]` is still a `string[]`. The export was unusable from TypeScript in every form. The constant now carries its element type (`readonly CruxFormFactor[]`), and CruxRunOptions.formFactors and CruxBatchOptions.formFactors accept a readonly array, so both the constant and a spread of it type-check. The comment no longer promises a fix that does not work. Also declares CruxMetric, which the README's exported-types table listed but which existed nowhere; it aliases chromeuxreport_v1.Schema$Metric. --- lib/crux-client.js | 12 ++++++++---- lib/crux.js | 1 + types/lib/crux-client.d.ts | 10 +++++----- types/lib/crux.d.ts | 1 + 4 files changed, 15 insertions(+), 9 deletions(-) diff --git a/lib/crux-client.js b/lib/crux-client.js index 055005e..2880d72 100644 --- a/lib/crux-client.js +++ b/lib/crux-client.js @@ -8,9 +8,13 @@ const CRUX_MAX_REQUESTS_PER_SECOND = 2.5; // Frozen because both are exported from crux and crux-history as the same instance, and // DEFAULT_CRUX_FORM_FACTORS is also the default parameter value inside the client: a consumer // pushing to it would silently add a form factor to every later call in the process, against -// a metered quota. Callers who need to extend them spread first, as lib/prompts.js does. +// a metered quota. +// +// Both carry an element type, not bare string[], and the formFactors options accept a +// readonly array — otherwise the exported constants could not be passed to the very functions +// they are the defaults for. Callers extending them spread first, as lib/prompts.js does. const CRUX_FORM_FACTORS = Object.freeze(/** @type {const} */ (['phone', 'desktop', 'tablet'])); -const DEFAULT_CRUX_FORM_FACTORS = Object.freeze(['phone', 'desktop']); +const DEFAULT_CRUX_FORM_FACTORS = Object.freeze(/** @type {readonly CruxFormFactor[]} */ (['phone', 'desktop'])); /** * @typedef {'phone'|'desktop'|'tablet'} CruxFormFactor @@ -27,7 +31,7 @@ const DEFAULT_CRUX_FORM_FACTORS = Object.freeze(['phone', 'desktop']); * CRUX_HISTORY_MAX_REQUESTS_PER_SECOND inert, so editing it would have changed nothing. * * @typedef {{ scope?: 'origin'|'page', formFactor?: CruxFormFactor }} CruxAuditOptions - * @typedef {{ scope?: 'origin'|'page', formFactors?: CruxFormFactor[], onNoData?: (formFactor: CruxFormFactor, message: string) => void }} CruxRunOptions + * @typedef {{ scope?: 'origin'|'page', formFactors?: readonly CruxFormFactor[], onNoData?: (formFactor: CruxFormFactor, message: string) => void }} CruxRunOptions */ /** @@ -35,7 +39,7 @@ const DEFAULT_CRUX_FORM_FACTORS = Object.freeze(['phone', 'desktop']); * @property {'origin'|'page'} [scope] * @property {number} [concurrency] * @property {number} [delayMs] - * @property {CruxFormFactor[]} [formFactors] + * @property {readonly CruxFormFactor[]} [formFactors] * @property {(completed: number, total: number, url: string, error: string|null, statusCode: number|null) => void} [onProgress] */ diff --git a/lib/crux.js b/lib/crux.js index 026b419..0cbacbc 100644 --- a/lib/crux.js +++ b/lib/crux.js @@ -18,6 +18,7 @@ const CRUX_API_URL = 'https://chromeuxreport.googleapis.com/v1/records:queryReco * extractedAt: string * }} CruxReport * + * @typedef {chromeuxreport_v1.Schema$Metric} CruxMetric * @typedef {{ url: string, formFactor: CruxFormFactor }} CruxWorkItem * @typedef {{ url: string, formFactor: CruxFormFactor, data: CruxReport|null, noData: boolean, error: string|null }} CruxBatchResult * @typedef {{ url: string, formFactor: CruxFormFactor, outputPath: string|null, noData: boolean, error: string|null }} CruxBatchWriteResult diff --git a/types/lib/crux-client.d.ts b/types/lib/crux-client.d.ts index 70c6bbc..88c0159 100644 --- a/types/lib/crux-client.d.ts +++ b/types/lib/crux-client.d.ts @@ -31,14 +31,14 @@ export type CruxAuditOptions = { }; export type CruxRunOptions = { scope?: "origin" | "page"; - formFactors?: CruxFormFactor[]; + formFactors?: readonly CruxFormFactor[]; onNoData?: (formFactor: CruxFormFactor, message: string) => void; }; export type CruxBatchOptions = { scope?: "origin" | "page" | undefined; concurrency?: number | undefined; delayMs?: number | undefined; - formFactors?: CruxFormFactor[] | undefined; + formFactors?: readonly CruxFormFactor[] | undefined; onProgress?: ((completed: number, total: number, url: string, error: string | null, statusCode: number | null) => void) | undefined; }; /** @@ -56,14 +56,14 @@ export type CruxBatchOptions = { * CRUX_HISTORY_MAX_REQUESTS_PER_SECOND inert, so editing it would have changed nothing. * * @typedef {{ scope?: 'origin'|'page', formFactor?: CruxFormFactor }} CruxAuditOptions - * @typedef {{ scope?: 'origin'|'page', formFactors?: CruxFormFactor[], onNoData?: (formFactor: CruxFormFactor, message: string) => void }} CruxRunOptions + * @typedef {{ scope?: 'origin'|'page', formFactors?: readonly CruxFormFactor[], onNoData?: (formFactor: CruxFormFactor, message: string) => void }} CruxRunOptions */ /** * @typedef {Object} CruxBatchOptions * @property {'origin'|'page'} [scope] * @property {number} [concurrency] * @property {number} [delayMs] - * @property {CruxFormFactor[]} [formFactors] + * @property {readonly CruxFormFactor[]} [formFactors] * @property {(completed: number, total: number, url: string, error: string|null, statusCode: number|null) => void} [onProgress] */ /** @@ -122,4 +122,4 @@ export function createCruxClient({ endpoint, command, dataLabel, periodKey, maxR }; export const CRUX_MAX_REQUESTS_PER_SECOND: 2.5; export const CRUX_FORM_FACTORS: readonly ["phone", "desktop", "tablet"]; -export const DEFAULT_CRUX_FORM_FACTORS: readonly string[]; +export const DEFAULT_CRUX_FORM_FACTORS: readonly CruxFormFactor[]; diff --git a/types/lib/crux.d.ts b/types/lib/crux.d.ts index dd3ef5f..e4f9a12 100644 --- a/types/lib/crux.d.ts +++ b/types/lib/crux.d.ts @@ -6,6 +6,7 @@ export type CruxReport = chromeuxreport_v1.Schema$Record & { url: string; extractedAt: string; }; +export type CruxMetric = chromeuxreport_v1.Schema$Metric; export type CruxWorkItem = { url: string; formFactor: CruxFormFactor; From 9ed03b3caa8b40e8e24f5557cf18fcb48d7b107e Mon Sep 17 00:00:00 2001 From: hugoer Date: Thu, 3 Sep 2026 14:22:04 +0200 Subject: [PATCH 06/11] fix(lab): keep runNumber and port out of the plan-level options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Folding LabWriteOptions into LabPlanOptions also published two options runLabPlan owns, and in both cases a caller-supplied value survives rather than being overridden: - runNumber is only replaced when repeats > 1, so `runLabPlan(urls, runs, { runNumber: 5 })` stamps every report in a single-run plan with the same `-run05` suffix; they avoid overwriting each other only because buildFilename appends `_NN` on collision. The typedef comment already said runNumber was not for callers — the type contradicted it. - port is only replaced when the plan launched its own Chrome, so passing it with reuseBrowser false attaches every run to the caller's single browser. That is the position-dependent scoring reuseBrowser warns about, reached with no warning at all. Both are now excluded via Omit. Also drops the trailing `-` from three @property tags, which was emitting doc comments whose first line was a bare dash into the published declarations. --- lib/lab.js | 20 +++++++++++++++----- types/lib/lab.d.ts | 37 +++++++++++++++++++++++++++---------- 2 files changed, 42 insertions(+), 15 deletions(-) diff --git a/lib/lab.js b/lib/lab.js index a62cd5e..6299305 100644 --- a/lib/lab.js +++ b/lib/lab.js @@ -62,14 +62,14 @@ const CHROME_FLAGS = [ * declaring it required promised consumers a field the default path deletes. * @property {Record} categories * @property {Record} audits - * @property {{ benchmarkIndex?: number, hostUserAgent?: string, networkUserAgent?: string }} [environment] - - * survives stripJsonProps, which drops only `i18n` and `timing`. buildRunSummary reads + * @property {{ benchmarkIndex?: number, hostUserAgent?: string, networkUserAgent?: string }} [environment] + * Survives stripJsonProps, which drops only `i18n` and `timing`. buildRunSummary reads * `environment.benchmarkIndex` from it. * @property {{ code: string, message?: string }} [runtimeError] - present when Lighthouse * resolved rather than threw: the page failed to load and the report carries no usable * metrics. runLabPlan treats a report with this set as a failed run. - * @property {{ formFactor?: 'desktop'|'mobile', [key: string]: unknown }} [configSettings] - - * also survives stripJsonProps. cleanLabReport hoists formFactor out of it, because + * @property {{ formFactor?: 'desktop'|'mobile', [key: string]: unknown }} [configSettings] + * Also survives stripJsonProps. cleanLabReport hoists formFactor out of it, because * Lighthouse 13 moved the field here from the report root. */ @@ -121,7 +121,17 @@ const CHROME_FLAGS = [ * the two halves genuinely are one options object to a caller. Declaring only the controls * made the published type reject `skipAudits`, `categories`, `blockedUrlPatterns`, * `stripJsonProps`, `clean` and `silent` — every option bin/web-perf.js actually passes. - * @typedef {LabPlanControls & LabWriteOptions} LabPlanOptions + * + * `runNumber` and `port` are excluded because runLabPlan owns both, and a caller-supplied + * value survives to do damage rather than being overridden: + * + * - `runNumber` is only replaced when `repeats > 1`, so passing it to a single-run plan + * stamps every report in the plan with the same `-runNN` suffix. + * - `port` is only replaced when the plan launched its own Chrome, so passing it with + * `reuseBrowser: false` shares one browser across every run anyway — the position-dependent + * scoring `reuseBrowser` warns about, with none of the warning. + * + * @typedef {LabPlanControls & Omit} LabPlanOptions */ function buildLighthouseConfig(labOptions, profileSettings = {}) { diff --git a/types/lib/lab.d.ts b/types/lib/lab.d.ts index 6752ad2..e3ee0be 100644 --- a/types/lib/lab.d.ts +++ b/types/lib/lab.d.ts @@ -40,8 +40,7 @@ export type LabReport = { categories: Record; audits: Record; /** - * - - * survives stripJsonProps, which drops only `i18n` and `timing`. buildRunSummary reads + * Survives stripJsonProps, which drops only `i18n` and `timing`. buildRunSummary reads * `environment.benchmarkIndex` from it. */ environment?: { @@ -59,8 +58,7 @@ export type LabReport = { message?: string; } | undefined; /** - * - - * also survives stripJsonProps. cleanLabReport hoists formFactor out of it, because + * Also survives stripJsonProps. cleanLabReport hoists formFactor out of it, because * Lighthouse 13 moved the field here from the report root. */ configSettings?: { @@ -169,8 +167,17 @@ export type LabWriteOptions = LabAuditOptions & { * the two halves genuinely are one options object to a caller. Declaring only the controls * made the published type reject `skipAudits`, `categories`, `blockedUrlPatterns`, * `stripJsonProps`, `clean` and `silent` — every option bin/web-perf.js actually passes. + * + * `runNumber` and `port` are excluded because runLabPlan owns both, and a caller-supplied + * value survives to do damage rather than being overridden: + * + * - `runNumber` is only replaced when `repeats > 1`, so passing it to a single-run plan + * stamps every report in the plan with the same `-runNN` suffix. + * - `port` is only replaced when the plan launched its own Chrome, so passing it with + * `reuseBrowser: false` shares one browser across every run anyway — the position-dependent + * scoring `reuseBrowser` warns about, with none of the warning. */ -export type LabPlanOptions = LabPlanControls & LabWriteOptions; +export type LabPlanOptions = LabPlanControls & Omit; export type LabAuditOptions = { /** * - attach to an already-running Chrome instead of launching one @@ -262,14 +269,14 @@ export function runLabToDisk(url: string, labOptions?: LabWriteOptions): Promise * declaring it required promised consumers a field the default path deletes. * @property {Record} categories * @property {Record} audits - * @property {{ benchmarkIndex?: number, hostUserAgent?: string, networkUserAgent?: string }} [environment] - - * survives stripJsonProps, which drops only `i18n` and `timing`. buildRunSummary reads + * @property {{ benchmarkIndex?: number, hostUserAgent?: string, networkUserAgent?: string }} [environment] + * Survives stripJsonProps, which drops only `i18n` and `timing`. buildRunSummary reads * `environment.benchmarkIndex` from it. * @property {{ code: string, message?: string }} [runtimeError] - present when Lighthouse * resolved rather than threw: the page failed to load and the report carries no usable * metrics. runLabPlan treats a report with this set as a failed run. - * @property {{ formFactor?: 'desktop'|'mobile', [key: string]: unknown }} [configSettings] - - * also survives stripJsonProps. cleanLabReport hoists formFactor out of it, because + * @property {{ formFactor?: 'desktop'|'mobile', [key: string]: unknown }} [configSettings] + * Also survives stripJsonProps. cleanLabReport hoists formFactor out of it, because * Lighthouse 13 moved the field here from the report root. */ /** @@ -318,7 +325,17 @@ export function runLabToDisk(url: string, labOptions?: LabWriteOptions): Promise * the two halves genuinely are one options object to a caller. Declaring only the controls * made the published type reject `skipAudits`, `categories`, `blockedUrlPatterns`, * `stripJsonProps`, `clean` and `silent` — every option bin/web-perf.js actually passes. - * @typedef {LabPlanControls & LabWriteOptions} LabPlanOptions + * + * `runNumber` and `port` are excluded because runLabPlan owns both, and a caller-supplied + * value survives to do damage rather than being overridden: + * + * - `runNumber` is only replaced when `repeats > 1`, so passing it to a single-run plan + * stamps every report in the plan with the same `-runNN` suffix. + * - `port` is only replaced when the plan launched its own Chrome, so passing it with + * `reuseBrowser: false` shares one browser across every run anyway — the position-dependent + * scoring `reuseBrowser` warns about, with none of the warning. + * + * @typedef {LabPlanControls & Omit} LabPlanOptions */ export function buildLighthouseConfig(labOptions: any, profileSettings?: {}): { extends: string; From c99c8aa314470ace01381988178f07b5ed52ae37 Mon Sep 17 00:00:00 2001 From: hugoer Date: Thu, 3 Sep 2026 14:22:04 +0200 Subject: [PATCH 07/11] fix: export the documented types from the package root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README line 532 tells consumers to write `import type { CruxReport, PsiReport, LabReport } from '@hugoer/web-perf-cli'`. That failed with TS2305: lib/index.js is a value-only façade of lazy getters, so types/lib/index.d.ts declared 23 functions and not one type — while package.json's top-level "types" field points at exactly that file. The root now re-declares every row of the README's exported-types table, so the documented import compiles. Found by extending the type guard to cover the package root, which it had never imported. --- lib/index.js | 18 +++++++ types/lib/index.d.ts | 118 ++++++++++++++++++++++++++++++++++--------- 2 files changed, 113 insertions(+), 23 deletions(-) diff --git a/lib/index.js b/lib/index.js index c8cdcf0..3f84ee1 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,3 +1,21 @@ +/** + * 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. + * + * @typedef {import('./lab').LabReport} LabReport + * @typedef {import('./lab').LabPlanResult} LabPlanResult + * @typedef {import('./psi').PsiReport} PsiReport + * @typedef {import('./psi').PsiBatchResult} PsiBatchResult + * @typedef {import('./crux').CruxReport} CruxReport + * @typedef {import('./crux').CruxMetric} CruxMetric + * @typedef {import('./crux').CruxBatchResult} CruxBatchResult + * @typedef {import('./crux').CruxFormFactor} CruxFormFactor + * @typedef {import('./crux-history').CruxHistoryReport} CruxHistoryReport + * @typedef {import('./crux-history').CruxHistoryBatchResult} CruxHistoryBatchResult + * @typedef {import('./variance').RunSummary} RunSummary + */ + const lazy = (loader) => { let cached; return () => { diff --git a/types/lib/index.d.ts b/types/lib/index.d.ts index 3de114c..8d58139 100644 --- a/types/lib/index.d.ts +++ b/types/lib/index.d.ts @@ -1,23 +1,95 @@ -export const runLabAudit: typeof import("./lab").runLabAudit; -export const runPsiAudit: typeof import("./psi").runPsiAudit; -export const runCruxAudit: typeof import("./crux").runCruxAudit; -export const runCruxHistoryAudit: typeof import("./crux-history").runCruxHistoryAudit; -export const runPsiAuditBatch: typeof import("./psi").runPsiAuditBatch; -export const runCruxAuditBatch: typeof import("./crux").runCruxAuditBatch; -export const runCruxHistoryAuditBatch: typeof import("./crux-history").runCruxHistoryAuditBatch; -export const runLab: typeof import("./lab").runLab; -export const runPsi: typeof import("./psi").runPsi; -export const runCrux: typeof import("./crux").runCrux; -export const runCruxHistory: typeof import("./crux-history").runCruxHistory; -export const runPsiBatch: typeof import("./psi").runPsiBatch; -export const runCruxBatch: typeof import("./crux").runCruxBatch; -export const runCruxHistoryBatch: typeof import("./crux-history").runCruxHistoryBatch; -export const buildLighthouseConfig: typeof import("./lab").buildLighthouseConfig; -export const CHROME_FLAGS: typeof import("./lab").CHROME_FLAGS; -export const DEFAULT_SKIP_AUDITS: typeof import("./lab").DEFAULT_SKIP_AUDITS; -export const sleep: typeof import("./utils").sleep; -export const createRateLimiter: typeof import("./utils").createRateLimiter; -export const normalizeOrigin: typeof import("./utils").normalizeOrigin; -export const PSI_MAX_REQUESTS_PER_SECOND: typeof import("./psi").PSI_MAX_REQUESTS_PER_SECOND; -export const CRUX_MAX_REQUESTS_PER_SECOND: typeof import("./crux").CRUX_MAX_REQUESTS_PER_SECOND; -export const CRUX_HISTORY_MAX_REQUESTS_PER_SECOND: typeof import("./crux-history").CRUX_HISTORY_MAX_REQUESTS_PER_SECOND; +declare namespace _exports { + export { LabReport, LabPlanResult, PsiReport, PsiBatchResult, CruxReport, CruxMetric, CruxBatchResult, CruxFormFactor, CruxHistoryReport, CruxHistoryBatchResult, RunSummary }; +} +declare namespace _exports { + const runLabAudit: typeof import("./lab").runLabAudit; + const runPsiAudit: typeof import("./psi").runPsiAudit; + const runCruxAudit: typeof import("./crux").runCruxAudit; + const runCruxHistoryAudit: typeof import("./crux-history").runCruxHistoryAudit; + const runPsiAuditBatch: typeof import("./psi").runPsiAuditBatch; + const runCruxAuditBatch: typeof import("./crux").runCruxAuditBatch; + const runCruxHistoryAuditBatch: typeof import("./crux-history").runCruxHistoryAuditBatch; + const runLab: typeof import("./lab").runLab; + const runPsi: typeof import("./psi").runPsi; + const runCrux: typeof import("./crux").runCrux; + const runCruxHistory: typeof import("./crux-history").runCruxHistory; + const runPsiBatch: typeof import("./psi").runPsiBatch; + const runCruxBatch: typeof import("./crux").runCruxBatch; + const runCruxHistoryBatch: typeof import("./crux-history").runCruxHistoryBatch; + const buildLighthouseConfig: typeof import("./lab").buildLighthouseConfig; + const CHROME_FLAGS: typeof import("./lab").CHROME_FLAGS; + const DEFAULT_SKIP_AUDITS: typeof import("./lab").DEFAULT_SKIP_AUDITS; + const sleep: typeof import("./utils").sleep; + const createRateLimiter: typeof import("./utils").createRateLimiter; + const normalizeOrigin: typeof import("./utils").normalizeOrigin; + const PSI_MAX_REQUESTS_PER_SECOND: typeof import("./psi").PSI_MAX_REQUESTS_PER_SECOND; + const CRUX_MAX_REQUESTS_PER_SECOND: typeof import("./crux").CRUX_MAX_REQUESTS_PER_SECOND; + const CRUX_HISTORY_MAX_REQUESTS_PER_SECOND: typeof import("./crux-history").CRUX_HISTORY_MAX_REQUESTS_PER_SECOND; +} +export = _exports; +/** + * 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 LabReport = import("./lab").LabReport; +/** + * 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 LabPlanResult = import("./lab").LabPlanResult; +/** + * 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 PsiReport = import("./psi").PsiReport; +/** + * 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 PsiBatchResult = import("./psi").PsiBatchResult; +/** + * 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 CruxReport = import("./crux").CruxReport; +/** + * 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 CruxMetric = import("./crux").CruxMetric; +/** + * 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 CruxBatchResult = import("./crux").CruxBatchResult; +/** + * 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 CruxFormFactor = import("./crux").CruxFormFactor; +/** + * 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 CruxHistoryReport = import("./crux-history").CruxHistoryReport; +/** + * 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 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 RunSummary = import("./variance").RunSummary; From 678987ca48def397ab53aac3a165029b4d901f70 Mon Sep 17 00:00:00 2001 From: hugoer Date: Thu, 3 Sep 2026 14:22:24 +0200 Subject: [PATCH 08/11] test: make the type guard cover the root, every subpath, and actually catch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard shipped with holes in exactly the places it was built to protect. It never imported the package root, so types/lib/index.d.ts — the file package.json's "types" field names — was the one declaration set it did not check. Covering it immediately found the README's documented root type import failing. It also skipped psi, sitemap, links, utils and profiles entirely. Its freeze assertion was vacuous: `const extended: string[] = [...DEFAULT, 'tablet']` compiles whether the source is readonly or not, because spreading a readonly array yields a mutable one. It is now a @ts-expect-error on a push, plus calls passing the constant to both CruxRunOptions and CruxBatchOptions — a spread compiles against either shape, so it covered neither typedef. runLabToDisk's parameter narrowed from `object` to LabWriteOptions in the previous commit with no call added, breaking the rule that same commit put in CLAUDE.md. A valid call cannot catch that widening back either, since every object literal is assignable to `object`; rejecting an unknown property can. skipLibCheck is off, so errors inside the shipped .d.ts files are no longer suppressed — the opposite of what a declaration guard should do. Every assertion was mutation-tested. Seven reverts, seven failures: un-freeze DEFAULT_CRUX_FORM_FACTORS -> caught narrow CruxRunOptions.formFactors -> caught runNumber/port back into LabPlanOptions -> caught drop CruxReport from the root -> caught runLabToDisk options back to `object` -> caught LabPlanOptions loses the run options -> caught onSummary reverts to bare object -> caught Two of those (formFactors, runLabToDisk) passed on the first attempt and were strengthened. --- type-tests/consumer.ts | 52 ++++++++++++++++++++++++++++++++++++---- type-tests/root.ts | 20 ++++++++++++++++ type-tests/subpaths.ts | 20 ++++++++++++++++ type-tests/tsconfig.json | 10 +++++--- 4 files changed, 95 insertions(+), 7 deletions(-) create mode 100644 type-tests/root.ts create mode 100644 type-tests/subpaths.ts diff --git a/type-tests/consumer.ts b/type-tests/consumer.ts index 150d786..8c11d96 100644 --- a/type-tests/consumer.ts +++ b/type-tests/consumer.ts @@ -8,8 +8,8 @@ // as a consumer's would. It is type-checked only — never executed, never published. // Run with: npm run check-types -import { runLabPlan, runLabAudit } from '@hugoer/web-perf-cli/lab'; -import { runCruxAudit, runCruxBatch, DEFAULT_CRUX_FORM_FACTORS } from '@hugoer/web-perf-cli/crux'; +import { runLabPlan, runLabAudit, runLabToDisk } from '@hugoer/web-perf-cli/lab'; +import { runCrux, runCruxAudit, runCruxBatch, DEFAULT_CRUX_FORM_FACTORS } from '@hugoer/web-perf-cli/crux'; import { runCruxHistoryAudit } from '@hugoer/web-perf-cli/crux-history'; import { buildRunSummary } from '@hugoer/web-perf-cli/variance'; import type { LabReport, LabPlanResult } from '@hugoer/web-perf-cli/lab'; @@ -72,7 +72,51 @@ export const batch = runCruxBatch(['https://example.com'], 'KEY', { onProgress: (done, total, url, error, statusCode) => `${done}/${total}${url}${error}${statusCode}`, }); -// --- the exported defaults are readonly ---------------------------------------------------- -export const extended: string[] = [...DEFAULT_CRUX_FORM_FACTORS, 'tablet']; +// --- the exported defaults are readonly, and usable ----------------------------------------- +// Passing the constant into the option it is the default for must compile. It did not before: +// `string[]` was never assignable to `CruxFormFactor[]`, so the export was unusable from TS. +export const withDefaults = runCruxBatch(['https://example.com'], 'KEY', { + formFactors: DEFAULT_CRUX_FORM_FACTORS, +}); + +// runCrux takes CruxRunOptions while runCruxBatch takes CruxBatchOptions, so the constant has +// to be passed to BOTH: a spread is a mutable array and compiles against either shape, which +// left the readonly widening on CruxRunOptions unguarded. +export const withDefaultsRun = runCrux('https://example.com', 'KEY', { + formFactors: DEFAULT_CRUX_FORM_FACTORS, +}); + +// The documented way to extend it must compile too. +export const withExtra = runCrux('https://example.com', 'KEY', { + formFactors: [...DEFAULT_CRUX_FORM_FACTORS, 'tablet'], +}); + +// And mutating it must NOT compile. This is the real freeze assertion: annotating a spread as +// `string[]` proved nothing, because spreading a readonly array yields a mutable one either +// way. If the freeze is reverted, this directive goes unused and tsc fails with +// "Unused '@ts-expect-error' directive". +// @ts-expect-error DEFAULT_CRUX_FORM_FACTORS is frozen and published as readonly +DEFAULT_CRUX_FORM_FACTORS.push('tablet'); + +// --- runLabToDisk's options are typed, not `object` ----------------------------------------- +export const toDisk = runLabToDisk('https://example.com', { + profile: 'low', clean: true, silent: true, skipAudits: ['uses-http2'], +}); + +// A valid call cannot detect the parameter widening back to `object` — every object literal is +// assignable to `object`. Rejecting an unknown property is what actually pins the type. +// @ts-expect-error runLabToDisk takes LabWriteOptions, not an untyped object +runLabToDisk('https://example.com', { notALabOption: true }); + +// runNumber and port are owned by runLabPlan and must NOT be accepted plan-level: a caller's +// value survives rather than being overridden. See the LabPlanOptions typedef. +export const rejectsRunNumber = runLabPlan(['u'], [{ profile: 'low' }], { + // @ts-expect-error runNumber is set by runLabPlan, not by callers + runNumber: 5, +}); +export const rejectsPort = runLabPlan(['u'], [{ profile: 'low' }], { + // @ts-expect-error port is owned by runLabPlan; passing it silently shares one browser + port: 9222, +}); export const audit = runLabAudit('https://example.com', { profile: 'low', silent: true }); diff --git a/type-tests/root.ts b/type-tests/root.ts new file mode 100644 index 0000000..6d00d61 --- /dev/null +++ b/type-tests/root.ts @@ -0,0 +1,20 @@ +// The package root: types/lib/index.d.ts is what package.json's top-level "types" field names +// and what most consumers resolve, yet it was the one declaration set the guard did not cover. +// Covering it immediately found that the type import the README documents did not compile. + +import { runCruxAudit, runPsiAudit, runLabAudit, CHROME_FLAGS, normalizeOrigin } from '@hugoer/web-perf-cli'; +import type { + LabReport, LabPlanResult, PsiReport, PsiBatchResult, + CruxReport, CruxMetric, CruxBatchResult, CruxFormFactor, + CruxHistoryReport, CruxHistoryBatchResult, RunSummary, +} from '@hugoer/web-perf-cli'; + +export const fns = [runCruxAudit, runPsiAudit, runLabAudit, normalizeOrigin]; +export const flags: readonly string[] = CHROME_FLAGS; + +// Every row of the README's "Key exported types" table must resolve from the root. +export type Rows = [ + LabReport, LabPlanResult, PsiReport, PsiBatchResult, + CruxReport, CruxMetric, CruxBatchResult, CruxFormFactor, + CruxHistoryReport, CruxHistoryBatchResult, RunSummary, +]; diff --git a/type-tests/subpaths.ts b/type-tests/subpaths.ts new file mode 100644 index 0000000..0a5a09a --- /dev/null +++ b/type-tests/subpaths.ts @@ -0,0 +1,20 @@ +// Every subpath in package.json "exports" must resolve to declarations, not to untyped JS. +import { runPsiAudit } from '@hugoer/web-perf-cli/psi'; +import { runSitemap, resolveSitemapUrl } from '@hugoer/web-perf-cli/sitemap'; +import { runLinks } from '@hugoer/web-perf-cli/links'; +import { PROFILES, resolveProfileSettings } from '@hugoer/web-perf-cli/profiles'; +import { buildFilename, withRetry, runBatch } from '@hugoer/web-perf-cli/utils'; +import { selectMedianRun, assessStability } from '@hugoer/web-perf-cli/variance'; +import type { PsiReport } from '@hugoer/web-perf-cli/psi'; + +export const psi: Promise = runPsiAudit('https://example.com', 'KEY', ['PERFORMANCE'], 'mobile'); +export const sitemap = runSitemap('https://example.com', 2, 0); +export const origin: string = resolveSitemapUrl('example.com').origin; +export const links = runLinks('https://example.com'); +export const profileNames: string[] = Object.keys(PROFILES); +export const settings = resolveProfileSettings({ profile: 'low' }); +export const name: string = buildFilename('https://example.com', 'lab', 'low'); +export const retried = withRetry(async () => 1, { maxRetries: 1 }); +export const batched = runBatch(['a'], async () => 1, { maxRequestsPerSecond: 1 }); +export const medianIndex: number = selectMedianRun([0.5, 0.6]); +export const stability: { stable: boolean; warnings: string[] } = assessStability([1000, 1100]); diff --git a/type-tests/tsconfig.json b/type-tests/tsconfig.json index c0979f6..c2e9630 100644 --- a/type-tests/tsconfig.json +++ b/type-tests/tsconfig.json @@ -1,8 +1,12 @@ { "compilerOptions": { - "noEmit": true, "strict": true, - "module": "node16", "moduleResolution": "node16", - "target": "ES2022", "skipLibCheck": true, "types": [] + "noEmit": true, + "strict": true, + "module": "node16", + "moduleResolution": "node16", + "target": "ES2022", + "skipLibCheck": false, + "types": [] }, "include": ["*.ts"] } From f12081d571c7c20ba1c4ac1cb593bb84595db42f Mon Sep 17 00:00:00 2001 From: hugoer Date: Thu, 3 Sep 2026 14:22:24 +0200 Subject: [PATCH 09/11] ci: enforce the type guard and check types/ for drift MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check-types existed but nothing ran it: lint.yml runs lint, test.yml runs tests, and its only enforcement was a line in CLAUDE.md. A contributor running `npm run lint && npm test` saw two green workflows and could merge broken declarations — the exact path every defect in #16 took. Nothing verified types/ was regenerated either, so check-types could compile against a stale committed declaration set and report green while the published types drifted from the implementation. The new workflow does both: regenerate, fail on any diff under types/ with a message naming the fix, then run check-types. Verified locally that the drift gate fires on a JSDoc change made without regenerating. --- .github/workflows/types.yml | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 .github/workflows/types.yml diff --git a/.github/workflows/types.yml b/.github/workflows/types.yml new file mode 100644 index 0000000..e6e2225 --- /dev/null +++ b/.github/workflows/types.yml @@ -0,0 +1,35 @@ +name: Types + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + types: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - run: npm ci + + # The committed declarations must match the current JSDoc. Without this, check-types + # would happily compile against a stale types/ and report green while the published + # declarations drift away from the implementation. + - name: Declarations are up to date + run: | + npm run generate-types + git diff --exit-code -- types/ \ + || { echo "::error::types/ is stale — run 'npm run generate-types' and commit the result"; exit 1; } + + # npm test exercises the implementation, not the .d.ts. This compiles a sample consumer + # against the published declarations, resolving through package.json "exports" exactly + # as a consumer's build would. + - run: npm run check-types From 4f91eadf91582546934f2238b2223b44f82d626f Mon Sep 17 00:00:00 2001 From: hugoer Date: Thu, 3 Sep 2026 14:22:24 +0200 Subject: [PATCH 10/11] docs: list the new public lab option types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splitting LabPlanOptions introduced LabPlanControls and LabWriteOptions, and changed what LabPlanOptions itself means — from controls-only to controls plus per-run options, minus the two runLabPlan owns. The Scripts table was updated but the exported-types table was not, which is the first of the three README staleness points CLAUDE.md names. Adds all four lab option types, including what LabPlanOptions now excludes and why. --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 73a62c0..c2d0848 100644 --- a/README.md +++ b/README.md @@ -549,6 +549,10 @@ Key exported types: | `CruxBatchResult` | `{ url, data: CruxReport \| null, error: string \| null }` | | `CruxHistoryBatchResult` | `{ url, data: CruxHistoryReport \| null, error: string \| null }` | | `LabPlanResult` | `{ url, profile, outputPath?, error? }` — one per run in a `runLabPlan` plan | +| `LabAuditOptions` | Options for a single `runLabAudit` call (`port`, `profile`, `network`, `device`, `categories`, `skipAudits`, `blockedUrlPatterns`, `stripJsonProps`, `silent`) | +| `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 | | `RunSummary` | Variance record for one repeated (URL x profile) pair: median, spread, `benchmarkIndex` range, per-metric arrays, stability warnings | ## Development From 602933e4a2f96e5a7106eb24e84a015e7308930e Mon Sep 17 00:00:00 2001 From: hugoer Date: Thu, 3 Sep 2026 14:22:35 +0200 Subject: [PATCH 11/11] fix: put "types" first in every exports condition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TypeScript documents that the "types" condition must come first, because conditions are matched in order. It was listed last in all ten subpaths, so tsc matched "require", found no .d.ts beside the .js, and only then fell back to "types". Confirmed with --traceResolution: every subpath now reports `Matched 'exports' condition 'types'` directly, with no failed require attempt. It resolved correctly by accident. The day anything emits declarations next to lib/*.js — or a consumer toolchain does not implement that fallback — every subpath would silently resolve to untyped JS while check-types still passed, because the guard exercises one resolution mode. --- package.json | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/package.json b/package.json index 2b8c299..cfbfe05 100644 --- a/package.json +++ b/package.json @@ -15,54 +15,54 @@ }, "exports": { ".": { + "types": "./types/lib/index.d.ts", "require": "./lib/index.js", - "import": "./lib/index.js", - "types": "./types/lib/index.d.ts" + "import": "./lib/index.js" }, "./lab": { + "types": "./types/lib/lab.d.ts", "require": "./lib/lab.js", - "import": "./lib/lab.js", - "types": "./types/lib/lab.d.ts" + "import": "./lib/lab.js" }, "./psi": { + "types": "./types/lib/psi.d.ts", "require": "./lib/psi.js", - "import": "./lib/psi.js", - "types": "./types/lib/psi.d.ts" + "import": "./lib/psi.js" }, "./crux": { + "types": "./types/lib/crux.d.ts", "require": "./lib/crux.js", - "import": "./lib/crux.js", - "types": "./types/lib/crux.d.ts" + "import": "./lib/crux.js" }, "./crux-history": { + "types": "./types/lib/crux-history.d.ts", "require": "./lib/crux-history.js", - "import": "./lib/crux-history.js", - "types": "./types/lib/crux-history.d.ts" + "import": "./lib/crux-history.js" }, "./utils": { + "types": "./types/lib/utils.d.ts", "require": "./lib/utils.js", - "import": "./lib/utils.js", - "types": "./types/lib/utils.d.ts" + "import": "./lib/utils.js" }, "./profiles": { + "types": "./types/lib/profiles.d.ts", "require": "./lib/profiles.js", - "import": "./lib/profiles.js", - "types": "./types/lib/profiles.d.ts" + "import": "./lib/profiles.js" }, "./links": { + "types": "./types/lib/links.d.ts", "require": "./lib/links.js", - "import": "./lib/links.js", - "types": "./types/lib/links.d.ts" + "import": "./lib/links.js" }, "./sitemap": { + "types": "./types/lib/sitemap.d.ts", "require": "./lib/sitemap.js", - "import": "./lib/sitemap.js", - "types": "./types/lib/sitemap.d.ts" + "import": "./lib/sitemap.js" }, "./variance": { + "types": "./types/lib/variance.d.ts", "require": "./lib/variance.js", - "import": "./lib/variance.js", - "types": "./types/lib/variance.d.ts" + "import": "./lib/variance.js" } }, "scripts": {