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 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..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 @@ -570,9 +574,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 diff --git a/lib/crux-client.js b/lib/crux-client.js index d0ce491..2880d72 100644 --- a/lib/crux-client.js +++ b/lib/crux-client.js @@ -5,8 +5,16 @@ 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. +// +// 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(/** @type {readonly CruxFormFactor[]} */ (['phone', 'desktop'])); /** * @typedef {'phone'|'desktop'|'tablet'} CruxFormFactor @@ -23,7 +31,7 @@ const DEFAULT_CRUX_FORM_FACTORS = ['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 */ /** @@ -31,7 +39,7 @@ const DEFAULT_CRUX_FORM_FACTORS = ['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-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/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/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/lib/lab.js b/lib/lab.js index 80c6137..6299305 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,32 @@ 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. + * + * `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 = {}) { const rawSkipAudits = labOptions.skipAudits || DEFAULT_SKIP_AUDITS; const disableFullPageScreenshot = rawSkipAudits.includes('full-page-screenshot'); @@ -194,7 +229,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/package.json b/package.json index b573b9b..cfbfe05 100644 --- a/package.json +++ b/package.json @@ -15,61 +15,62 @@ }, "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": { "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..8c11d96 --- /dev/null +++ b/type-tests/consumer.ts @@ -0,0 +1,122 @@ +// 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, 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'; +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, 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 new file mode 100644 index 0000000..c2e9630 --- /dev/null +++ b/type-tests/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "noEmit": true, + "strict": true, + "module": "node16", + "moduleResolution": "node16", + "target": "ES2022", + "skipLibCheck": false, + "types": [] + }, + "include": ["*.ts"] +} diff --git a/types/lib/crux-client.d.ts b/types/lib/crux-client.d.ts index 7710ee4..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: 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; 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; diff --git a/types/lib/lab.d.ts b/types/lib/lab.d.ts index c59ae7b..e3ee0be 100644 --- a/types/lib/lab.d.ts +++ b/types/lib/lab.d.ts @@ -39,6 +39,32 @@ 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 +117,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 +152,32 @@ 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. + * + * `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 & Omit; export type LabAuditOptions = { /** * - attach to an already-running Chrome instead of launching one @@ -176,10 +228,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 +269,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 +301,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 +313,30 @@ 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. + * + * `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; settings: any;