From 1cbc1a2e0f0542a1c4bb5256592944fb6db4c169 Mon Sep 17 00:00:00 2001 From: hugoer Date: Fri, 4 Sep 2026 09:38:17 +0200 Subject: [PATCH 1/2] test: verify the published tarball is complete and loadable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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, and type-tests/ resolves the package by self-reference. A lib/ module left out of "files" resolves in both, passes CI, and breaks only for a real consumer after publish. package-exports.test.js does not close the gap either — `require('.')` from inside lib/ lands on lib/index.js directly and never consults the root "exports" map or any subpath. This packs the tarball, extracts it, and requires all ten published subpaths by name from a directory where the extracted copy is the installed dependency, so resolution runs through "exports" as a consumer's would. The package's own dependencies are symlinked from node_modules rather than installed, keeping the test offline and under two seconds; the trade-off is that an undeclared dependency would still resolve, so a separate assertion reads the packed source for that. Verified to fail on three mutations: types/ dropped from "files", an exports subpath pointed at a missing file, and an undeclared require added to runtime code. Refs #23 --- CLAUDE.md | 7 ++ lib/package-tarball.test.js | 150 ++++++++++++++++++++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 lib/package-tarball.test.js 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/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([]); + }); +}); From aab10fdb47e8ee63b8aa53b4c410b7e55eb27b93 Mon Sep 17 00:00:00 2001 From: hugoer Date: Fri, 4 Sep 2026 09:42:48 +0200 Subject: [PATCH 2/2] docs: index the runnable examples from the README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit examples/ holds 21 scripts, ~1,180 lines, all tracked in git, and the README never mentioned them once — `grep -n "examples/" README.md` returned nothing, so no reader was ever pointed at them and their documentation value was zero. Adds a "Runnable examples" section under Library API that links every script to what it demonstrates, grouped by command family. It also writes down the two conventions that make the directory navigable rather than a matrix of 21 similar filenames: `-audit` prints and writes nothing, `-save` writes JSON under results/. Prerequisites are stated per family — Chrome for lab, an API key for psi/crux/crux-history. Verified by running examples/crux-audit.js and examples/crux-save.js against the live API, and by checking every link resolves and every one of the 21 scripts appears exactly once. --- README.md | 56 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) 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.