# Browser-measurement harness

Shared rig for the Core Web Vitals experiments behind PageSpeedFix articles.
Node-side build benchmarks (bundle sizes, dev-server timings) live in each
article's own `fixtures/`; anything that needs a real browser — LCP, CLS, INP,
waterfalls, throttling — should use this instead of rebuilding it per article.

Install once:

```bash
cd evidence/lib && npm install
```

Chrome comes from the local puppeteer cache (`~/.cache/puppeteer/chrome`),
pinned rather than the auto-updating Chrome.app, so a version is quotable in
the article. `findChrome()` reports which build it picked.

## Measuring a page

```js
import { measurePage, startStaticServer } from '../../lib/index.mjs';

const server = await startStaticServer('./fixtures/dist');
const result = await measurePage({
  url: server.url('/hero-preload.html'),
  runs: 9,
  network: 'slow4g',   // DevTools preset; see NETWORK
  cpuRate: 4,
  viewport: 'moto_g',
});
await server.close();
```

Every run gets a fresh browser context and a cleared HTTP cache, so all runs are
cold. `result.metrics` carries `{ n, min, median, max, mean, stddev, spread }`
for LCP, TTFB, FCP, CLS, INP, transfer bytes, and a long-task blocking
approximation — never a bare median, because articles must publish spread
(rubric D1, skill rule 2). `result.runs` keeps every raw record, including the
per-request network log and the layout-shift sources.

`result.metrics.lcpPhases` is the four-phase web.dev breakdown — TTFB, resource
load delay, resource load time, element render delay — which is what separates
"the image is too big" from "the image was found too late".

### Interactions and INP

Pass `interact(page)` to measure INP:

```js
await measurePage({ url, runs: 9, interact: async (page) => { await page.click('#filter'); } });
```

**Event Timing does not work in headless Chrome.** A post-load click runs its
handler and produces frames, but Chrome emits no `event` entries at all, so INP
comes back `null`. Verified on Chrome 150: headless 0 entries, headful 3, same
page and same click. `measurePage` therefore defaults to `headless: false`
whenever `interact` is passed, and windows open offscreen at `-2400,0`. If a run
still records no interactions, the record carries an `inpWarning` rather than
silently publishing a null.

### Statistics

`compare(a, b)` returns the delta *and* whether the two run sets overlap, so an
article never reports a difference that sits inside its own noise band
(skill rule 8). `linearFit(points)` fits a sweep so the article can publish a
per-unit cost and fixed overhead instead of two anecdotes (skill rule 3).

## Unedited tool-output artifacts

Author-drawn charts are necessary but not sufficient — the reader has to see one
screen the tool produced (skill rule 1). Two are available, neither needing
macOS screen-recording permission.

```js
const lh = await runLighthouse({ url, outDir: '../reports', name: 'baseline' });
// -> baseline.report.html, baseline.report.json, baseline-report.png

const dt = await captureDevTools({ url, outFile: '../screenshots/waterfall.png', throttling: 'Slow 4G' });
```

`captureDevTools` opens Chrome's real DevTools frontend — Chrome serves it over
the remote-debugging port — as an ordinary page and screenshots it. The
throttling dropdown in the image reads "Slow 4G" because it genuinely is set;
the reload is driven by clicking the panel's own "Reload page" button.

Two things it took a while to find, both worth keeping:

- The inspected browser needs `--remote-allow-origins`. Without it Chrome 111+
  rejects the frontend's websocket and the panel renders
  "Debugging connection was closed".
- Nothing else may hold a CDP session on the inspected page. Puppeteer attaching
  to the same target evicts the frontend, so the inspected page runs in a
  separate Chrome process and every action goes through the DevTools UI.

`screencapture` is deliberately not used: it needs a TCC permission this repo
cannot assume, and it fails with "could not create image from display" when the
terminal lacks it.

## Conditions are recorded, not assumed

`result.conditions` carries the network preset (with its actual kbps/latency),
the CPU throttling multiplier, the viewport, run count, cold-cache flag,
headless flag, the exact Chrome build, and the machine's CPU/cores/platform.
Copy it into the article's methodology section verbatim; `benchmarkIndex(page)`
gives Lighthouse's BenchmarkIndex if the article needs to say what device class
the throttled machine actually lands on rather than asserting "mid-range
Android".
