openvibers/openvibe.shared Commits
Measure Core Web Vitals in perf-budget with an optional Chrome pass (#5)
Changes
@@@ -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 tohashed names with a `manifest.json` lookup. The builder does not transcode images.@@@ -128,6 +128,35 @@ The command writes `dist/img/manifest.json`, mapping paths such as `hero.jpg` tonames as immutable. Shared does not transcode images: generate AVIF and WebP variants in thesite 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@@@ -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');}nnew file mode 100644@@@ -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); });@@@ -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');
@@ -6,6 +6,12 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
@@ -6,6 +6,12 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
## Unreleased
## 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
- The asset pipeline adds `hashAsset(bytes)`, generated AVIF and WebP srcsets from
`picture({ src, widths, ... })`, and `openvibe-build-assets` to copy files to
`picture({ src, widths, ... })`, and `openvibe-build-assets` to copy files to
hashed names with a `manifest.json` lookup. The builder does not transcode images.
hashed names with a `manifest.json` lookup. The builder does not transcode images.
@@ -128,6 +128,35 @@ The command writes `dist/img/manifest.json`, mapping paths such as `hero.jpg` to
@@ -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
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`.
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)
### Metrics and readiness (server)
```js
```js
@@ -1,14 +1,14 @@
@@ -1,14 +1,14 @@
'use strict';
'use strict';
/**
/**
* openvibe-shared/perf-budget — size budgets for a page's first load (roadmap WS-T task 1), measured
* 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 { measure, check, format } = require('openvibe-shared/perf-budget');
* const m = await measure({ base: 'http://127.0.0.1:4000', path: '/' });
* const m = await measure({ base: 'http://127.0.0.1:4000', path: '/' });
* const over = check(m, { htmlBrotliKB: 30, jsFiles: 12, jsBrotliKB: 90, cssBrotliKB: 40 });
* const over = check(m, { htmlBrotliKB: 30, jsFiles: 12, jsBrotliKB: 90, cssBrotliKB: 40 });
* assert.deepStrictEqual(over, [], format(m, over));
* 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
* (<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 },
* { html: { rawKB, brotliKB }, js: { files, rawKB, brotliKB }, css: { files, rawKB, brotliKB },
* external: [url], urls: { js: [..], css: [..] } }
* external: [url], urls: { js: [..], css: [..] } }
@@ -16,8 +16,21 @@
@@ -16,8 +16,21 @@
* origins (the OpenVibe Frame from openvibe.network, a CDN) are listed in `external` and counted in
* 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.
* `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:
* check(m, budgets) → [{ name, value, budget }] for every budget exceeded. Budget names:
* htmlRawKB htmlBrotliKB jsFiles jsRawKB jsBrotliKB cssFiles cssRawKB cssBrotliKB externalFiles
* 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.
* Set each a little above a measurement; raising one should be a decision said in the commit.
*/
*/
const zlib = require('zlib');
const zlib = require('zlib');
@@ -47,7 +60,62 @@ function assetsOf(html) {
@@ -47,7 +60,62 @@ function assetsOf(html) {
return { js: js.filter(Boolean), css: css.filter(Boolean) };
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 origin = new URL(base).origin;
const pageUrl = new URL(path, base).href;
const pageUrl = new URL(path, base).href;
const res = await f(pageUrl, { headers: { accept: 'text/html', ...headers } });
const res = await f(pageUrl, { headers: { accept: 'text/html', ...headers } });
@@ -69,7 +137,9 @@ async function measure({ base, path = '/', fetch: f = globalThis.fetch, headers
@@ -69,7 +137,9 @@ async function measure({ base, path = '/', fetch: f = globalThis.fetch, headers
}
}
const jsT = await total(js);
const jsT = await total(js);
const cssT = await total(css);
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 = {
const READ = {
@@ -77,7 +147,12 @@ const READ = {
@@ -77,7 +147,12 @@ const READ = {
jsFiles: (m) => m.js.files, jsRawKB: (m) => m.js.rawKB, jsBrotliKB: (m) => m.js.brotliKB,
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,
cssFiles: (m) => m.css.files, cssRawKB: (m) => m.css.rawKB, cssBrotliKB: (m) => m.css.brotliKB,
externalFiles: (m) => m.external.length,
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 = {}) {
function check(m, budgets = {}) {
const over = [];
const over = [];
@@ -91,6 +166,7 @@ function check(m, budgets = {}) {
@@ -91,6 +166,7 @@ function check(m, budgets = {}) {
function format(m, over = []) {
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`];
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}`);
for (const o of over) lines.push(` over budget: ${o.name} ${o.value} > ${o.budget}`);
return lines.join('\n');
return lines.join('\n');
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,54 @@
@@ -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); });
@@ -33,6 +33,17 @@ const { measure, check, format, assetsOf } = require('../perf-budget');
@@ -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.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.match(format(m, check(m, { jsFiles: 1 })), /over budget: jsFiles 2 > 1/);
assert.throws(() => check(m, { jsKb: 1 }), /unknown budget/);
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).
// A named asset that is missing fails the measurement (a broken page is not a small page).
files['/'] = files['/'].replace('/a.js', '/gone.js');
files['/'] = files['/'].replace('/a.js', '/gone.js');