diff --git a/CLAUDE.md b/CLAUDE.md index 2e7df0a..f99e561 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -177,6 +177,13 @@ CLI flags (`--api-key`, `--api-key-path`) take precedence over environment varia npm test # vitest ``` +`lib/package-tarball.test.js` is the one test that does not exercise `lib/` source. It runs +`npm pack`, extracts the tarball, and requires every published subpath by name from a directory +where the extracted copy is the installed dependency. It is the only guard that reads +`package.json` "files": `examples/` installs with `file:..`, which symlinks the repo root rather +than packing it, and `type-tests/` resolves the package by self-reference. A `lib/` module left +out of "files" therefore passes both, passes CI, and breaks only for a consumer after publish. + ## Development Checklist Run these in order at the end of every task, without exception: diff --git a/README.md b/README.md index c2d0848..4bfe463 100644 --- a/README.md +++ b/README.md @@ -523,6 +523,62 @@ writes each report plus any `.summary.json`, and reports progress through `onRun `onRunComplete` / `onRunError` / `onSummary` callbacks rather than logging. It returns `Promise`. +### Runnable examples + +[`examples/`](examples/) holds 21 scripts covering every library entry point. They depend on the +package as `file:..`, so they always run against the working tree rather than a published version: + +```bash +npm install --prefix examples # once +node examples/lab-audit.js # then, from the repo root +``` + +Two conventions run through the directory. A script ending in **`-audit` prints to the console and +writes nothing**; one ending in **`-save` writes JSON under `results/`** and prints the paths. The +`lab-*` scripts need Chrome installed locally; the `psi-*`, `crux-*` and `crux-history-*` scripts +need `WEB_PERF_PSI_API_KEY` set (see [API key](#google-cloud-api-key-for-psi-crux-crux-history)). + +#### `lab` — local Lighthouse + +| Script | What it demonstrates | +|--------|----------------------| +| [`lab-audit.js`](examples/lab-audit.js) | One audit through headless Chrome, printed to the console | +| [`lab-save.js`](examples/lab-save.js) | The same audit written to `results/lab/` | +| [`lab-audit-profiles.js`](examples/lab-audit-profiles.js) | The `low` / `medium` / `high` presets run back to back and compared | +| [`lab-save-profiles.js`](examples/lab-save-profiles.js) | One file per profile, with the profile name in the filename | +| [`lab-audit-custom-throttling.js`](examples/lab-audit-custom-throttling.js) | Explicit `network` + `device` instead of a preset, plus `blockedUrlPatterns` | +| [`lab-audit-variance.js`](examples/lab-audit-variance.js) | Why repeated runs disagree, and using the pure `variance` helpers to pick a median | +| [`lab-save-runs.js`](examples/lab-save-runs.js) | `runLabPlan` driving a full (URL x profile x repeat) matrix through its callbacks | + +#### `psi` — PageSpeed Insights + +| Script | What it demonstrates | +|--------|----------------------| +| [`psi-audit.js`](examples/psi-audit.js) | One URL; category scores and Core Web Vitals to the console | +| [`psi-save.js`](examples/psi-save.js) | The full API response written to `results/psi/` | +| [`psi-audit-categories.js`](examples/psi-audit-categories.js) | Requesting only `PERFORMANCE` and `SEO`, which is faster than all four | +| [`psi-batch-audit.js`](examples/psi-batch-audit.js) | Many URLs concurrently, rate-limited to the PSI quota | +| [`psi-batch-save.js`](examples/psi-batch-save.js) | The same batch, one file per URL, with a progress line each | + +#### `crux` — CrUX 28-day rolling average + +| Script | What it demonstrates | +|--------|----------------------| +| [`crux-audit.js`](examples/crux-audit.js) | Page-level metric distributions for one URL | +| [`crux-audit-origin.js`](examples/crux-audit-origin.js) | `scope: 'origin'` — every page aggregated, for a high-level check | +| [`crux-save.js`](examples/crux-save.js) | One file per form factor (phone + desktop by default, so two) | +| [`crux-batch-audit.js`](examples/crux-batch-audit.js) | Many URLs concurrently, printed as a summary table | +| [`crux-batch-save.js`](examples/crux-batch-save.js) | The same batch written to `results/crux/` | + +#### `crux-history` — ~6 months of weekly CrUX data + +| Script | What it demonstrates | +|--------|----------------------| +| [`crux-history-audit.js`](examples/crux-history-audit.js) | The LCP trend for one URL, period by period | +| [`crux-history-save.js`](examples/crux-history-save.js) | One file per form factor under `results/crux-history/` | +| [`crux-history-batch-audit.js`](examples/crux-history-batch-audit.js) | Oldest vs latest LCP p75 per URL — improved or regressed | +| [`crux-history-batch-save.js`](examples/crux-history-batch-save.js) | The same batch written to disk | + ## TypeScript TypeScript type declarations are included and resolve automatically when you install the package. No `@types/` package needed. diff --git a/lib/package-tarball.test.js b/lib/package-tarball.test.js new file mode 100644 index 0000000..efcacf6 --- /dev/null +++ b/lib/package-tarball.test.js @@ -0,0 +1,150 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; + +const { execFileSync } = require('child_process'); +const fs = require('fs'); +const { builtinModules } = require('module'); +const os = require('os'); +const path = require('path'); + +const pkg = require('../package.json'); + +// Does the PUBLISHED TARBALL work? +// +// Both consumer-facing guards read the working tree, so neither consults package.json "files": +// - examples/ installs with `file:..`, which SYMLINKS the repo root rather than packing it. +// - type-tests/ resolves the package through self-reference. +// A lib/ module left out of "files" therefore resolves in both, passes CI, and breaks only for a +// real consumer after publish. package-exports.test.js does not cover it either: `require('.')` +// from inside lib/ lands on lib/index.js directly, never touching the root "exports" map. +// +// This packs the tarball, extracts it, and requires every published subpath BY NAME from a +// directory where the extracted copy is the installed dependency — so resolution runs through +// "exports" exactly as a consumer's would. +// +// The package's own dependencies are symlinked from the repo's node_modules rather than +// installed, which keeps this offline and ~2s instead of a full network install. The trade-off is +// that a dependency used but never declared would still resolve, so the last test covers that by +// reading the source instead. + +const REPO_ROOT = path.join(__dirname, '..'); + +/** Package-name specifiers for every subpath in "exports": '.' -> '@scope/name'. */ +const SPECIFIERS = Object.keys(pkg.exports).map( + (subpath) => path.posix.join(pkg.name, subpath), +); + +/** Every packed .js file that ships as runtime code — test files are not it. */ +function runtimeFiles(root) { + const found = []; + const walk = (dir) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + walk(full); + } else if (entry.name.endsWith('.js') && !entry.name.endsWith('.test.js')) { + found.push(full); + } + } + }; + for (const dir of ['bin', 'lib']) { + walk(path.join(root, dir)); + } + return found; +} + +describe('published tarball', () => { + let workdir; + let extracted; + let loaded; + + beforeAll(() => { + workdir = fs.mkdtempSync(path.join(os.tmpdir(), 'web-perf-tarball-')); + const packDir = path.join(workdir, 'pack'); + fs.mkdirSync(packDir); + + // --cache keeps this off the user's shared npm cache, which can be unwritable. + const tarball = execFileSync( + 'npm', + ['pack', '--silent', '--pack-destination', packDir, '--cache', path.join(workdir, 'npm-cache')], + { cwd: REPO_ROOT, encoding: 'utf8' }, + ).trim(); + execFileSync('tar', ['-xzf', path.join(packDir, tarball), '-C', packDir]); + extracted = path.join(packDir, 'package'); + + fs.symlinkSync(path.join(REPO_ROOT, 'node_modules'), path.join(extracted, 'node_modules')); + + // The extracted copy has to sit in a node_modules under its published name, or requiring + // it by name would never consult the "exports" map. + const consumer = path.join(workdir, 'consumer'); + fs.mkdirSync(path.join(consumer, 'node_modules', path.dirname(pkg.name)), { recursive: true }); + fs.symlinkSync(extracted, path.join(consumer, 'node_modules', pkg.name)); + + // Loading happens in a child process: this test file lives in the repo, where the same + // specifiers would resolve by self-reference and prove nothing about the tarball. + const probe = path.join(consumer, 'probe.js'); + fs.writeFileSync(probe, [ + 'const out = {};', + `for (const spec of ${JSON.stringify(SPECIFIERS)}) {`, + ' try {', + ' out[spec] = { names: Object.keys(require(spec)).length };', + ' } catch (err) {', + ' out[spec] = { error: err.message.split(String.fromCharCode(10))[0] };', + ' }', + '}', + 'process.stdout.write(JSON.stringify(out));', + ].join('\n')); + loaded = JSON.parse(execFileSync(process.execPath, [probe], { cwd: consumer, encoding: 'utf8' })); + }, 120000); + + afterAll(() => { + if (workdir) { + fs.rmSync(workdir, { recursive: true, force: true }); + } + }); + + it('ships every file "exports" points at, in every condition', () => { + const missing = []; + for (const [subpath, conditions] of Object.entries(pkg.exports)) { + for (const [condition, target] of Object.entries(conditions)) { + if (!fs.existsSync(path.join(extracted, target))) { + missing.push(`${subpath} [${condition}] -> ${target}`); + } + } + } + expect(missing).toEqual([]); + }); + + it('ships the "main", "types" and "bin" entrypoints', () => { + const entrypoints = [pkg.main, pkg.types, ...Object.values(pkg.bin)]; + const missing = entrypoints.filter((rel) => !fs.existsSync(path.join(extracted, rel))); + expect(missing).toEqual([]); + }); + + it.each(SPECIFIERS)('%s loads from an installed copy', (spec) => { + // Reported as the error string rather than a boolean so a failure names the missing file. + expect(loaded[spec].error ?? null).toBeNull(); + expect(loaded[spec].names).toBeGreaterThan(0); + }); + + it('declares every dependency its runtime code requires', () => { + const declared = new Set(Object.keys(pkg.dependencies)); + const undeclared = new Set(); + + for (const file of runtimeFiles(extracted)) { + const source = fs.readFileSync(file, 'utf8'); + // Dynamic import() is matched too: lighthouse is only ever reached that way. + const bare = [...source.matchAll(/(?:require|import)\(\s*['"]([^'"]+)['"]\s*\)/g)] + .map(([, spec]) => spec) + .filter((spec) => !spec.startsWith('.') && !spec.startsWith('node:')); + + for (const spec of bare) { + const name = spec.startsWith('@') ? spec.split('/').slice(0, 2).join('/') : spec.split('/')[0]; + if (!builtinModules.includes(name) && !declared.has(name)) { + undeclared.add(`${path.relative(extracted, file)} -> ${name}`); + } + } + } + + expect([...undeclared]).toEqual([]); + }); +});