openvibers/openvibe.shared Commits

Measure Core Web Vitals in perf-budget with an optional Chrome pass (#5)

OP OpenVibers committed 2 Oct 2026 · 8dfe597932 · 5 files, +180 −4

local checks ✓ 3/3 · CI ✓ 3/3 · review SHIP by space-bunny-alpha

https://github.com/OpenVibers/OpenVibe.Shared/pull/5

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 452f4f1..263e33f 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -6,6 +6,12 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
++- `perf-budget` measures Core Web Vitals (plan T11). `measure({ browser })` opens the page in
++ Chrome through `browser-harness` after the byte pass, clicks once, and adds
++ `cwv: { lcpMs, inpMs, cls }`. `check()` accepts `lcpMs`, `inpMs` and `cls` budgets and throws
++ when they are used without `cwv`; `format()` prints a `cwv:` line. Without `browser`,
++ measurement is unchanged and needs no Chrome.
++
- The asset pipeline adds `hashAsset(bytes)`, generated AVIF and WebP srcsets from
`picture({ src, widths, ... })`, and `openvibe-build-assets` to copy files to
hashed names with a `manifest.json` lookup. The builder does not transcode images.
ddiff --git a/README.md b/README.md
iindex 0dc7783..7b0bea2 100644
---- a/README.md
++++ b/README.md
@@@ -128,6 +128,35 @@ The command writes `dist/img/manifest.json`, mapping paths such as `hero.jpg` to
names as immutable. Shared does not transcode images: generate AVIF and WebP variants in the
site build before using `widths`, or pass existing variants through `sources`.
++### Performance budgets (`perf-budget.js`)
++
++`measure({ base, path })` weighs a page's first load from the running server (HTML, same-origin
++scripts and stylesheets, raw and brotli) with no browser. `measure({ ..., browser: true })` then
++opens the page in Chrome through `browser-harness`, clicks once at the viewport centre and adds
++`cwv: { lcpMs, inpMs, cls }`, so `check()` takes Core Web Vitals budgets as well. The shared
++test workflow needs no extra step for this, because `ubuntu-latest` has `/usr/bin/google-chrome`.
++Run the check from `extra`:
++
++```yaml
++jobs:
++ test:
++ uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@<sha>
++ with:
++ extra: node scripts/perf/cwv.js
++```
++
++```js
++// scripts/perf/cwv.js: start the site, measure the home page in Chrome, fail over budget.
++const { measure, check, format } = require('openvibe-shared/perf-budget');
++const server = require('../../server/app').listen(0, '127.0.0.1', async () => {
++ const m = await measure({ base: `http://127.0.0.1:${server.address().port}`, path: '/', browser: true });
++ const over = check(m, { htmlBrotliKB: 30, jsBrotliKB: 90, lcpMs: 2500, inpMs: 200, cls: 0.1 });
++ console.log(format(m, over));
++ server.close();
++ process.exitCode = over.length ? 1 : 0;
++});
++```
++
### Metrics and readiness (server)
```js
ddiff --git a/perf-budget.js b/perf-budget.js
iindex 50906f9..2f75aa9 100644
---- a/perf-budget.js
++++ b/perf-budget.js
@@@ -1,14 +1,14 @@
'use strict';
/**
* openvibe-shared/perf-budget — size budgets for a page's first load (roadmap WS-T task 1), measured
-- * from what the running server actually answers: no browser, deterministic, cheap enough for `npm test`.
++ * from what the running server actually answers: no browser, deterministic, cheap enough for `npm test`. Core Web Vitals are opt-in: `browser` adds a real-Chrome pass.
*
* const { measure, check, format } = require('openvibe-shared/perf-budget');
* const m = await measure({ base: 'http://127.0.0.1:4000', path: '/' });
* const over = check(m, { htmlBrotliKB: 30, jsFiles: 12, jsBrotliKB: 90, cssBrotliKB: 40 });
* assert.deepStrictEqual(over, [], format(m, over));
*
-- * measure({ base, path = '/', fetch, headers }) fetches the page, then every same-origin script
++ * measure({ base, path = '/', fetch, headers, browser }) fetches the page, then every same-origin script
* (<script src>) and stylesheet (<link rel="stylesheet">, not media="print") it names, and answers
* { html: { rawKB, brotliKB }, js: { files, rawKB, brotliKB }, css: { files, rawKB, brotliKB },
* external: [url], urls: { js: [..], css: [..] } }
@@@ -16,8 +16,21 @@
* origins (the OpenVibe Frame from openvibe.network, a CDN) are listed in `external` and counted in
* `files`, not in bytes: they are the other origin's budget. Inline <script>/<style> count in the HTML.
*
++ * With `browser` (true for launch() defaults, an options object for browser-harness launch(), or
++ * { running } for a browser already started with launch()), the page is then opened at 1280×900 in a fresh
++ * browser context, one mouse click lands at the viewport centre, and 500 ms later the result gains
++ * cwv: { lcpMs, inpMs, cls }
++ * lcpMs the last largest-contentful-paint entry's startTime (null when none); inpMs the longest 'event'
++ * entry with an interactionId (0 when none); cls the largest session window of layout shifts without recent
++ * input (shifts under 1 s apart, at most 5 s long; the web-vitals definition), 3 decimals. A browser this
++ * call launched is stopped again; a running one is left running.
++ *
++ * const m = await measure({ base, path: '/', browser: true });
++ * check(m, { lcpMs: 2500, inpMs: 200, cls: 0.1 });
++ *
* check(m, budgets) → [{ name, value, budget }] for every budget exceeded. Budget names:
* htmlRawKB htmlBrotliKB jsFiles jsRawKB jsBrotliKB cssFiles cssRawKB cssBrotliKB externalFiles
++ * lcpMs inpMs cls (these need measure({ browser }); without m.cwv check() throws)
* Set each a little above a measurement; raising one should be a decision said in the commit.
*/
const zlib = require('zlib');
@@@ -47,7 +60,62 @@ function assetsOf(html) {
return { js: js.filter(Boolean), css: css.filter(Boolean) };
}
--async function measure({ base, path = '/', fetch: f = globalThis.fetch, headers = {} } = {}) {
++// Installed before any page script: the Core Web Vitals entries, buffered so none before it is missed.
++const CWV_PROBE = `(() => {
++ if (window.__ovCwv) return;
++ const s = { lcp: [], shifts: [], events: [] };
++ const watch = (type, fn, extra) => {
++ try { new PerformanceObserver((list) => list.getEntries().forEach(fn)).observe({ type, buffered: true, ...extra }); } catch { /* unsupported */ }
++ };
++ watch('largest-contentful-paint', (e) => s.lcp.push(e.startTime));
++ watch('layout-shift', (e) => { if (!e.hadRecentInput) s.shifts.push([e.startTime, e.value]); });
++ watch('event', (e) => { if (e.interactionId > 0) s.events.push(e.duration); }, { durationThreshold: 16 });
++ Object.defineProperty(window, '__ovCwv', { value: () => s });
++})();`;
++const VIEWPORT = { width: 1280, height: 900 };
++
++/** The largest session window of layout shifts ([startTime, value]): under 1 s apart, at most 5 s long. */
++function clsOf(shifts) {
++ let max = 0, sum = 0, first = 0, last = 0;
++ for (const [t, v] of [...shifts].sort((a, b) => a[0] - b[0])) {
++ if (sum && t - last < 1000 && t - first < 5000) sum += v;
++ else { sum = v; first = t; }
++ last = t;
++ max = Math.max(max, sum);
++ }
++ return Math.round(max * 1000) / 1000;
++}
++
++/** Opens the page in Chrome with the probe, clicks once at the viewport centre, and reads the vitals. */
++async function vitals(url, browser) {
++ const harness = require('./browser-harness');
++ const own = !browser.running;
++ const b = browser.running || await harness.launch(browser === true ? {} : browser);
++ let context = null, page = null;
++ try {
++ context = await b.newContext();
++ page = await harness.openPage(b, context, VIEWPORT);
++ await page.send('Page.addScriptToEvaluateOnNewDocument', { source: CWV_PROBE });
++ await page.goto(url);
++ const at = { x: VIEWPORT.width / 2, y: VIEWPORT.height / 2, button: 'left', clickCount: 1 };
++ await page.send('Input.dispatchMouseEvent', { type: 'mousePressed', ...at });
++ await page.send('Input.dispatchMouseEvent', { type: 'mouseReleased', ...at });
++ await new Promise((r) => setTimeout(r, 500));
++ const raw = await page.evaluate('window.__ovCwv ? window.__ovCwv() : null');
++ if (!raw) throw new Error(`perf-budget: the vitals probe did not run on ${url}`);
++ return {
++ lcpMs: raw.lcp.length ? Math.round(raw.lcp[raw.lcp.length - 1]) : null,
++ inpMs: Math.round(Math.max(0, ...raw.events)),
++ cls: clsOf(raw.shifts),
++ };
++ } finally {
++ if (page) await page.close();
++ if (context) await context.dispose();
++ if (own) await b.close();
++ }
++}
++
++async function measure({ base, path = '/', fetch: f = globalThis.fetch, headers = {}, browser = null } = {}) {
const origin = new URL(base).origin;
const pageUrl = new URL(path, base).href;
const res = await f(pageUrl, { headers: { accept: 'text/html', ...headers } });
@@@ -69,7 +137,9 @@ async function measure({ base, path = '/', fetch: f = globalThis.fetch, headers
}
const jsT = await total(js);
const cssT = await total(css);
-- return { url: pageUrl, html: { rawKB: kb(html.length), brotliKB: kb(brotli(html)) }, js: jsT, css: cssT, external, urls: { js, css } };
++ const m = { url: pageUrl, html: { rawKB: kb(html.length), brotliKB: kb(brotli(html)) }, js: jsT, css: cssT, external, urls: { js, css } };
++ if (browser) m.cwv = await vitals(pageUrl, browser);
++ return m;
}
const READ = {
@@@ -77,7 +147,12 @@ const READ = {
jsFiles: (m) => m.js.files, jsRawKB: (m) => m.js.rawKB, jsBrotliKB: (m) => m.js.brotliKB,
cssFiles: (m) => m.css.files, cssRawKB: (m) => m.css.rawKB, cssBrotliKB: (m) => m.css.brotliKB,
externalFiles: (m) => m.external.length,
++ lcpMs: (m) => vital(m, 'lcpMs'), inpMs: (m) => vital(m, 'inpMs'), cls: (m) => vital(m, 'cls'),
};
++function vital(m, name) {
++ if (!m.cwv) throw new Error(`perf-budget: ${name} needs measure({ browser })`);
++ return m.cwv[name];
++}
function check(m, budgets = {}) {
const over = [];
@@@ -91,6 +166,7 @@ function check(m, budgets = {}) {
function format(m, over = []) {
const lines = [`${m.url}: html ${m.html.rawKB} KB (${m.html.brotliKB} br), js ${m.js.files} files ${m.js.rawKB} KB (${m.js.brotliKB} br), css ${m.css.files} files ${m.css.rawKB} KB (${m.css.brotliKB} br), ${m.external.length} external`];
++ if (m.cwv) lines.push(` cwv: lcp ${m.cwv.lcpMs ?? 'none'} ms, inp ${m.cwv.inpMs} ms, cls ${m.cwv.cls}`);
for (const o of over) lines.push(` over budget: ${o.name} ${o.value} > ${o.budget}`);
return lines.join('\n');
}
ddiff --git a/test/perf-budget-chrome.test.js b/test/perf-budget-chrome.test.js
nnew file mode 100644
iindex 0000000..e8d06e4
---- /dev/null
++++ b/test/perf-budget-chrome.test.js
@@@ -0,0 +1,54 @@
++'use strict';
++// openvibe-shared/perf-budget measure({ browser }) in a real headless Chrome against a local page whose text
++// is pushed down by a late block: the shift, the paint and the click are measured. Its own file so a missing
++// Chrome skips only this case (exit 0) and not the byte checks in perf-budget.test.js; OV_SKIP_BROWSER=1 too.
++const assert = require('assert');
++const http = require('http');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const h = require('../browser-harness');
++const { measure, check, format } = require('../perf-budget');
++
++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {
++ console.log('perf-budget-chrome: skipped (no Chrome; set CHROME_BIN)');
++ process.exit(0);
++}
++
++const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>CWV</title><link rel="icon" href="data:,"></head><body>
++<main id="m"><h1>Vitals</h1><p>${'Server-rendered text that a late block pushes down the page. '.repeat(20)}</p></main>
++<script>setTimeout(() => { const d = document.createElement('div'); d.style.height = '300px'; d.textContent = 'late block';
++ document.getElementById('m').prepend(d); }, 200);
++document.addEventListener('click', () => { const t = performance.now(); while (performance.now() - t < 40) { /* busy */ } });</script></body></html>`;
++
++(async () => {
++ const server = http.createServer((req, res) => {
++ if (req.url !== '/') { res.writeHead(404); res.end('no'); return; }
++ res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); res.end(PAGE);
++ });
++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-perf-budget-test-'));
++ try {
++ // launch() options: this call starts Chrome and stops it again.
++ const m = await measure({ base, browser: { tmpDir } });
++ console.log('perf-budget-chrome: cwv', JSON.stringify(m.cwv));
++ assert.ok(m.cwv.cls > 0, `the late block shifts the text: ${JSON.stringify(m.cwv)}`);
++ assert.ok(m.cwv.lcpMs > 0, 'the text paints');
++ assert.ok(m.cwv.inpMs >= 0 && Number.isInteger(m.cwv.inpMs), 'the click is measured');
++ assert.deepStrictEqual(check(m, { cls: 0 }), [{ name: 'cls', value: m.cwv.cls, budget: 0 }]);
++ assert.match(format(m), /cwv: lcp \d+ ms, inp \d+ ms, cls [\d.]+/);
++
++ // A running browser is reused and left running.
++ const running = await h.launch({ tmpDir });
++ try {
++ const again = await measure({ base, browser: { running } });
++ assert.ok(again.cwv.cls > 0 && again.cwv.lcpMs > 0, JSON.stringify(again.cwv));
++ assert.strictEqual(running.closed, false, 'a browser the caller started stays open');
++ } finally { await running.close(); }
++ } finally {
++ server.close();
++ fs.rmSync(tmpDir, { recursive: true, force: true });
++ }
++ console.log('perf-budget-chrome: all checks passed');
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex c8366b9..2d9b5ff 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -33,6 +33,17 @@ const { measure, check, format, assetsOf } = require('../perf-budget');
assert.deepStrictEqual(check(m, { jsFiles: 1 }), [{ name: 'jsFiles', value: 2, budget: 1 }]);
assert.match(format(m, check(m, { jsFiles: 1 })), /over budget: jsFiles 2 > 1/);
assert.throws(() => check(m, { jsKb: 1 }), /unknown budget/);
++ assert.strictEqual(m.cwv, undefined, 'no browser, no vitals');
++
++ // Core Web Vitals budgets read m.cwv (from measure({ browser }), Chrome-backed in perf-budget-chrome.test.js).
++ for (const name of ['lcpMs', 'inpMs', 'cls']) assert.throws(() => check(m, { [name]: 1 }), new RegExp(`perf-budget: ${name} needs measure\\(\\{ browser \\}\\)`));
++ const v = { ...m, cwv: { lcpMs: 1800, inpMs: 120, cls: 0.05 } };
++ assert.deepStrictEqual(check(v, { lcpMs: 2500, inpMs: 200, cls: 0.1, jsFiles: 2 }), [], 'under every budget');
++ assert.deepStrictEqual(check(v, { lcpMs: 1500, inpMs: 100, cls: 0.01 }), [
++ { name: 'lcpMs', value: 1800, budget: 1500 }, { name: 'inpMs', value: 120, budget: 100 }, { name: 'cls', value: 0.05, budget: 0.01 },
++ ]);
++ assert.match(format(v), /\n {2}cwv: lcp 1800 ms, inp 120 ms, cls 0\.05$/);
++ assert.doesNotMatch(format(m), /cwv/);
// A named asset that is missing fails the measurement (a broken page is not a small page).
files['/'] = files['/'].replace('/a.js', '/gone.js');