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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
56 changes: 56 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<LabPlanResult[]>`.

### 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.
Expand Down
150 changes: 150 additions & 0 deletions lib/package-tarball.test.js
Original file line number Diff line number Diff line change
@@ -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([]);
});
});
Loading