openvibers/openvibe.shared Commits

v1.18.0: update telemetry (roadmap WS-P task 14): release-watch counts the update prompt (prompted: optional, required, window, contract) and beats every 5 minutes with a random in-memory tab id and its release, saying ended on pagehide; the collector keeps tabs heard from in 12 minutes as release_client_sessions{generation=current|older} against the release the process serves. release-watch stays under its 5 KB brotli budget (4.96 KB) by shortening comments

OP OpenVibers committed 26 Sept 2026 · e1f7441498 · 8 files, +149 −41


Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 8115d2e..79c7ca8 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,13 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
breaking change to any exported module, browser global or served file name is a new major.
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
++## 1.18.0 — 2026-09-26
++
++**Update telemetry** (roadmap WS-P task 14).
++- **Prompts are counted.** `release-watch.js` records `prompted` when it shows the update prompt, with reason `optional`, or `required`, `window` or `contract` when a reload must follow. `release.js` accepts the new outcome into `release_client_updates_total`.
++- **Sessions by generation.** Each tab sends a beat to the metrics URL once its manifest is read and then every 5 minutes, carrying a random 16-hex id kept in memory only and the release it runs. On `pagehide` it sends `ended`. The collector keeps the tabs heard from in the last 12 minutes, at most 20,000 per registry, and exposes the gauge `release_client_sessions{generation="current"|"older"}`. `current` is the release the process serves: `mount` and `collect` pass it, or pass `collect(registry, { currentRelease })` yourself. Sec-GPC/DNT requests are still not counted.
++- **Size.** release-watch stays within its 5 KB brotli budget (4.96 KB); comments were shortened to make room.
++
## 1.17.1 — 2026-09-26
Fix: `release-watch.js` acts on a `host.release.published` event only when its `origin` (when present) is the page's own. A service id alone is not unique across the network: Sites' placeholder pages reuse product ids (`ai`, `coupons`, …), so a placeholder's release made the real product's tabs check for nothing.
ddiff --git a/README.md b/README.md
iindex 2e36fd0..0f610fc 100644
---- a/README.md
++++ b/README.md
@@@ -214,10 +214,13 @@ release.mount(app, { registry: m.registry }); // GET /release.json, POST /rele
- **CSP.** The site's CSP `connect-src` must allow `https://events.openvibe.network`. Otherwise the
browser refuses the stream once, and release-watch stops (state `blocked`) and keeps polling.
- **State.** `OVRelease.state().realtime` reports `{ state, service, url, events, ignored, checks, failures, lastSeq }`.
--- **Metrics (D46)**: `release_client_updates_total{outcome,reason}`, where outcome is `applied`,
-- `reloaded`, `deferred` or `failed`. The tab finds the endpoint in the manifest's `metrics_url`
-- (set by `mount`), the meta tag's `data-metrics`, or `OpenVibeNavbar.init({ releaseWatch:
-- { metricsUrl } })`.
++- **Metrics (D46)**: `release_client_updates_total{outcome,reason}`, where outcome is `prompted`
++ (reason `optional`, `required`, `window` or `contract`), `applied`, `reloaded`, `deferred` or
++ `failed`. `release_client_sessions{generation}` counts open tabs heard from in the last 12
++ minutes, `current` when they run the release this process serves and `older` otherwise: each tab
++ beats every 5 minutes with a random id it keeps in memory only (never tied to an account) and
++ says when it ends. The tab finds the endpoint in the manifest's `metrics_url` (set by `mount`),
++ the meta tag's `data-metrics`, or `OpenVibeNavbar.init({ releaseWatch: { metricsUrl } })`.
Mixed-version test: capture the previous release's `/release.json` (its `full()` form) and the
calls its pages make as fixtures, then:
ddiff --git a/package-lock.json b/package-lock.json
iindex 216c993..bebb045 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "1.17.1",
++ "version": "1.18.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "1.17.1",
++ "version": "1.18.0",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
ddiff --git a/package.json b/package.json
iindex 3ea9e01..a5ac0b9 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "1.17.1",
++ "version": "1.18.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"exports": {
ddiff --git a/release-watch.js b/release-watch.js
iindex 26c9b7e..7f783ed 100644
---- a/release-watch.js
++++ b/release-watch.js
@@@ -5,7 +5,8 @@
* server changes are applied in place; anything else prompts. It reloads by itself only when it must
* (min_client_release, the mixed-version window, contracts out of range) and only when safe: hidden or idle
* 2 minutes, no focused field, nothing protected (form[data-dirty="true"], [data-ov-protected], playing
-- * media, a live camera/mic, window.OVProtected()). Outcomes are beaconed to the metrics URL (D46).
++ * media, a live camera/mic, window.OVProtected()). Outcomes are beaconed to the metrics URL (D46), with a
++ * 5-minute beat (random tab id, release) for sessions by generation.
* Release notifications (1.17.0, WS-P task 9): one anonymous EventSource on the Events realtime stream
* (topic host.release.published, public). An event naming this page's service with a release it neither
* runs nor knows runs check(true) after a 0-20 s jitter, at most once per 30 s. Polling stays the fallback.
@@@ -32,6 +33,8 @@
let busyWith = null; // the plan/apply in flight
let pending = null; let waiting = []; let kinds = ''; let failedRelease = null; let lib = null;
let counts = {}; const totals = {}; const noted = {};
++ let session = null; let lastBeat = 0;
++ try { session = Array.from(root.crypto.getRandomValues(new Uint8Array(8)), (b) => (b | 256).toString(16).slice(1)).join(''); } catch { /* no beats */ }
['keydown', 'pointerdown', 'input', 'wheel', 'touchstart'].forEach((t) => root.addEventListener(t, () => { lastInput = Date.now(); }, { passive: true, capture: true }));
@@@ -41,12 +44,17 @@
if (once) { if (noted[k]) return; noted[k] = 1; }
for (const o of [counts, totals]) { const r = o[outcome] || (o[outcome] = {}); r[reason] = (r[reason] || 0) + 1; }
}
-- function flush() {
++ // beat: send even with nothing counted; ended: the tab is going
++ function flush(beat, ended) {
const to = cfg.metricsUrl || (meta && meta.getAttribute('data-metrics')) || (latest && latest.metrics_url);
-- if (!to || !Object.keys(counts).length) return;
++ const any = Object.keys(counts).length > 0;
++ if (!to || (!any && !(beat === true && session && current))) return;
try {
if (new URL(to, root.location.href).origin !== new URL(root.location.href).origin) return;
-- const body = JSON.stringify({ service: latest && latest.service, release: current, to: latest && latest.release, counts });
++ const report = { service: latest && latest.service, release: current, to: latest && latest.release };
++ if (any) report.counts = counts;
++ if (session) { report.session = session; lastBeat = Date.now(); if (ended === true) report.ended = true; }
++ const body = JSON.stringify(report);
counts = {};
const n = root.navigator;
if (!(n && n.sendBeacon && n.sendBeacon(to, body))) root.fetch(to, { method: 'POST', body, keepalive: true, credentials: 'same-origin', headers: { 'Content-Type': 'text/plain' } }).catch(() => {});
@@@ -78,6 +86,7 @@
function prompt() {
if (prompted) return;
prompted = true;
++ record('prompted', mustReload ? reloadWhy : 'optional');
if (root.OpenVibeUI && typeof root.OpenVibeUI.toast === 'function') {
root.OpenVibeUI.toast('A new version of this page is available.', { type: 'info', title: 'Update ready', ttl: 0, action: { label: 'Reload', onClick: () => reload('user') } });
}
@@@ -167,11 +176,9 @@
return m;
}
-- // ── Release notifications: OpenVibe.Host publishes host.release.published (public) to OpenVibe.Events ──
-- // One EventSource per tab, without credentials: the events are public, so an account switch changes
-- // nothing (and a second load of this script returns early). Closed after 5 min hidden (the poll and the
-- // visibility check cover a hidden tab), reopened on return with last_event_id; errors back off 30 s to
-- // 15 min, and after 6 failures in a row only polling is left.
++ // ── Release notifications (host.release.published, public, via OpenVibe.Events) ──
++ // One credential-less EventSource per tab (account switches change nothing). Closed after 5 min hidden,
++ // reopened with last_event_id; errors back off 30 s to 15 min; after 6 failures only polling is left.
const TOPIC = 'host.release.published';
const HEX = /^[0-9a-f]{7,40}$/;
const rt = { state: 'off', service: null, url: null, events: 0, ignored: 0, checks: 0, failures: 0, lastSeq: null };
@@@ -224,8 +231,7 @@
if (!p || ev.event_type !== TOPIC) return;
if (typeof m.seq === 'number') { if (rt.lastSeq != null && m.seq <= rt.lastSeq) return; rt.lastSeq = m.seq; }
if (p.service !== rt.service || typeof p.release !== 'string') return;
-- // A service id alone is not unique across the network (Sites' placeholder pages reuse product ids),
-- // so when the event names the origin it went live on, it must be this page's.
++ // Service ids repeat across origins (Sites' placeholders): a named origin must be this page's.
if (p.origin) { try { if (new URL(p.origin).origin !== root.location.origin) { rt.ignored++; return; } } catch { return; } }
rt.events++;
if (seen.includes(ev.event_id) || same(p.release, current) || same(p.release, latest && latest.release) || same(p.release, queued)) { rt.ignored++; return; }
@@@ -248,7 +254,7 @@
let stopped = false;
root.addEventListener('focus', () => { if (!stopped) check(false); });
root.addEventListener('online', () => { if (stopped) return; if (rt.state === 'failed') { rt.state = 'off'; rt.failures = 0; live(); } check(true); });
-- root.addEventListener('pagehide', flush);
++ root.addEventListener('pagehide', () => flush(true, true));
document.addEventListener('visibilitychange', () => {
if (stopped) return;
if (document.hidden) { flush(); maybeReload(); if (es) later('hide', hide, 5 * 60 * 1000); return; }
@@@ -260,6 +266,7 @@
if (stopped) return;
if (pending && commitRegions(pending)) adopt(pending);
check(false); maybeReload();
++ if (Date.now() - lastBeat >= 3e5) flush(true);
}, 30 * 1000);
const poll = root.setInterval(() => { if (!stopped) check(true); }, 10 * 60 * 1000);
function stop() {
@@@ -273,8 +280,7 @@
flush,
stop,
};
-- // The page's own manifest, if /release.json still serves the release the page was rendered from. A site
-- // that serves no /release.json and rendered no meta tag is left alone.
++ // The page's own manifest (if still served). No /release.json and no meta tag: left alone.
lastCheck = Date.now();
root.fetch(url, { cache: 'no-store', credentials: 'omit' })
.then((r) => (r.ok ? r.json() : null))
@@@ -284,6 +290,7 @@
if (m.release === current) base = m;
consider(m);
live();
++ flush(true);
})
.catch(() => { if (!current) stop(); });
})(typeof window !== 'undefined' ? window : globalThis);
ddiff --git a/release.js b/release.js
iindex 3491c8e..1a589e8 100644
---- a/release.js
++++ b/release.js
@@@ -210,6 +210,7 @@ function deepFreeze(o) {
// ── /release-metrics: what open tabs report (D46) ──────────────
const OUTCOMES = {
++ prompted: /^(optional|required|window|contract)$/,
applied: /^(content|server|style)(\+(content|server|style)){0,2}$/,
reloaded: /^(user|required|window|contract)$/,
deferred: /^(typing|dirty|protected|media|capture|active)$/,
@@@ -218,6 +219,37 @@ const OUTCOMES = {
const MAX_BODY = 4096;
const MAX_PER_REASON = 50;
const counters = new WeakMap();
++// Open tabs by generation (WS-P task 14): each tab beats every 5 minutes with a random per-tab id (never
++// stored in the browser, never tied to an account); a tab not heard from in 12 minutes, or that said it
++// ended, is gone. At most MAX_SESSIONS are tracked per registry.
++const SESSION_TTL_MS = 12 * 60 * 1000;
++const MAX_SESSIONS = 20000;
++const SESSION_RE = /^[0-9a-f]{16,32}$/;
++const sessionStores = new WeakMap();
++
++function sessionStore(registry, currentRelease) {
++ let st = sessionStores.get(registry);
++ if (!st) {
++ st = { map: new Map(), current: null };
++ registry.gauge({
++ name: 'release_client_sessions', help: 'Open tabs heard from in the last 12 minutes, by whether they run the release this process serves (current) or an older one',
++ labelNames: ['generation'],
++ collect: () => {
++ const cutoff = Date.now() - SESSION_TTL_MS;
++ const cur = typeof st.current === 'function' ? st.current() : null;
++ let current = 0; let older = 0;
++ for (const [id, v] of st.map) {
++ if (v.at < cutoff) { st.map.delete(id); continue; }
++ if (cur && v.release === cur) current++; else older++;
++ }
++ return [{ labels: { generation: 'current' }, value: current }, { labels: { generation: 'older' }, value: older }];
++ },
++ });
++ sessionStores.set(registry, st);
++ }
++ if (currentRelease) st.current = currentRelease;
++ return st;
++}
function updateCounter(registry) {
let c = counters.get(registry);
@@@ -244,15 +276,18 @@ function readBody(req) {
}
/**
-- * POST handler for release-watch beacons: { counts: { applied|reloaded|deferred|failed: { reason: n } } }.
-- * Counts go to release_client_updates_total{outcome,reason} in `registry` (openvibe-shared/metrics).
++ * POST handler for release-watch beacons: { counts: { prompted|applied|reloaded|deferred|failed: { reason: n } },
++ * session, release, ended }. Counts go to release_client_updates_total{outcome,reason} in `registry`
++ * (openvibe-shared/metrics); a session beat (with or without counts) keeps that tab in
++ * release_client_sessions{generation}, current when its release is `currentRelease()`, and `ended` drops it.
* Unknown reasons count as "other"; each reason counts at most 50 per report; a report is at most 4 KB;
* one client (req.ip, which honours the app's trust proxy; or `keyOf(req)`) sends at most `perMinute`
* reports a minute. Sec-GPC/DNT requests are not counted.
*/
--function collector(registry, { perMinute = 30, keyOf = null } = {}) {
++function collector(registry, { perMinute = 30, keyOf = null, currentRelease = null } = {}) {
if (!registry || typeof registry.counter !== 'function') throw new TypeError('release: collect() needs an openvibe-shared/metrics registry');
const counter = updateCounter(registry);
++ const sessions = sessionStore(registry, currentRelease);
const seen = new Map(); let windowStart = Date.now();
return async function releaseMetrics(req, res) {
const done = (code) => { res.statusCode = code; res.setHeader('Cache-Control', 'no-store'); res.end(); };
@@@ -268,10 +303,16 @@ function collector(registry, { perMinute = 30, keyOf = null } = {}) {
let body = await readBody(req);
if (body === null) return done(413);
if (typeof body === 'string') { try { body = JSON.parse(body); } catch { return done(400); } }
-- const counts = body && typeof body === 'object' ? body.counts : null;
-- if (!counts || typeof counts !== 'object') return done(400);
++ if (!body || typeof body !== 'object') return done(400);
++ const counts = body.counts && typeof body.counts === 'object' ? body.counts : null;
++ const sid = typeof body.session === 'string' && SESSION_RE.test(body.session) ? body.session : null;
++ if (!counts && !sid) return done(400);
++ if (sid) {
++ if (body.ended === true) sessions.map.delete(sid);
++ else if (sessions.map.size < MAX_SESSIONS || sessions.map.has(sid)) sessions.map.set(sid, { release: typeof body.release === 'string' ? body.release.slice(0, 80) : null, at: now });
++ }
for (const [outcome, re] of Object.entries(OUTCOMES)) {
-- const reasons = counts[outcome];
++ const reasons = counts && counts[outcome];
if (!reasons || typeof reasons !== 'object') continue;
for (const [reason, value] of Object.entries(reasons).slice(0, 16)) {
const k = Math.min(MAX_PER_REASON, Math.floor(Number(value)));
@@@ -373,7 +414,7 @@ function createRelease({
function mount(app, { registry = null, path: manifestPath = '/release.json', metricsPath: mp = metricsPath || '/release-metrics', perMinute } = {}) {
app.get(manifestPath, handler);
if (registry) {
-- app.post(mp, collector(registry, { perMinute }));
++ app.post(mp, collector(registry, { perMinute, currentRelease: () => state.full.release }));
if (metricsUrl !== mp) { metricsUrl = mp; build(); }
}
return api;
@@@ -396,7 +437,7 @@ function createRelease({
handler,
metaTag,
mount,
-- collect: (registry, opts) => collector(registry, opts),
++ collect: (registry, opts) => collector(registry, { currentRelease: () => state.full.release, ...opts }),
refresh,
validate,
warnings,
ddiff --git a/test/release-update.test.js b/test/release-update.test.js
iindex 3a92787..c96556a 100644
---- a/test/release-update.test.js
++++ b/test/release-update.test.js
@@@ -67,6 +67,7 @@ const counts = (p) => p.beacons.reduce((acc, b) => { for (const [o, rs] of Objec
// can change something in place. Measured 2026-09-23: 3.2 KB and 3.0 KB brotli (release-watch was 1.7 KB).
// 2026-09-26 (1.17.0): release-watch 4.7 KB with the release notifications over Events realtime (+1.1 KB of
// code, +0.4 KB of comments); its budget moved from 3.5 to 5 KB for that, and only for that.
++// 1.18.0: session beats and the prompted count (+0.25 KB) fit by shortening comments: 4.96 KB.
{
const fs = require('fs'); const path = require('path'); const { brotli } = require('../scripts/size-report');
const size = (f) => brotli(fs.readFileSync(path.join(__dirname, '..', f)));
@@@ -212,11 +213,11 @@ const counts = (p) => p.beacons.reduce((acc, b) => { for (const [o, rs] of Objec
{
const p = await open({ hidden: false });
await update(p, { ...M2, contract_ranges: api('2.0.0', '^2.0.0') });
-- assert.deepStrictEqual([p.reloads, p.metrics()], [0, { deferred: { active: 1 } }], 'incompatible contracts: a reload, but not under the user');
++ assert.deepStrictEqual([p.reloads, p.metrics()], [0, { prompted: { contract: 1 }, deferred: { active: 1 } }], 'incompatible contracts: a reload (prompted), but not under the user');
assert.strictEqual(hrefs(p)[0], '/css/app.css?v=111111111111', 'and nothing in place');
p.setHidden(true);
assert.strictEqual(p.reloads, 1);
-- assert.deepStrictEqual(counts(p), { deferred: { active: 1 }, reloaded: { contract: 1 } });
++ assert.deepStrictEqual(counts(p), { prompted: { contract: 1 }, deferred: { active: 1 }, reloaded: { contract: 1 } });
}
{
// A page one release behind whose server kept accepting it: in place (the N-1 → N window).
ddiff --git a/test/release.test.js b/test/release.test.js
iindex bd2373b..2ac0f00 100644
---- a/test/release.test.js
++++ b/test/release.test.js
@@@ -160,6 +160,34 @@ const valid11 = (m) => { const ok = validate11(m); return ok || JSON.stringify(v
assert.strictEqual(await post({ counts: {} }), 204);
assert.strictEqual(await post({ counts: {} }), 429, 'an address sends at most perMinute reports a minute');
++ // Sessions by generation (WS-P task 14): a beat with a per-tab id keeps the tab in
++ // release_client_sessions{generation}; current is the release this process serves; ended drops it.
++ {
++ const reg = createRegistry();
++ let serving = 'r2';
++ const c2 = r.collect(reg, { perMinute: 100, currentRelease: () => serving });
++ const send = async (payload) => {
++ const req = Readable.from([Buffer.from(JSON.stringify(payload))]);
++ req.headers = {}; req.socket = { remoteAddress: '10.1.0.1' };
++ const res = { statusCode: 0, setHeader() {}, end() {} };
++ await c2(req, res);
++ return res.statusCode;
++ };
++ const gauge = (g) => { const line = reg.metrics().split('\n').find((l) => l.startsWith(`release_client_sessions{generation="${g}"}`)); return line ? Number(line.split(' ').pop()) : null; };
++ assert.strictEqual(await send({ session: 'a'.repeat(16), release: 'r2' }), 204, 'a beat needs no counts');
++ assert.strictEqual(await send({ session: 'b'.repeat(16), release: 'r1', counts: { prompted: { required: 1 } } }), 204);
++ assert.strictEqual(await send({ session: 'c'.repeat(16), release: 'r1' }), 204);
++ assert.strictEqual(await send({ session: 'not-hex', release: 'r1' }), 400, 'no counts and no valid session');
++ assert.deepStrictEqual([gauge('current'), gauge('older')], [1, 2]);
++ assert.match(reg.metrics(), /release_client_updates_total\{outcome="prompted",reason="required"\} 1/, 'prompts are counted');
++ assert.strictEqual(await send({ session: 'c'.repeat(16), release: 'r1', ended: true }), 204);
++ assert.deepStrictEqual([gauge('current'), gauge('older')], [1, 1], 'an ended tab is gone at once');
++ serving = 'r3';
++ assert.deepStrictEqual([gauge('current'), gauge('older')], [0, 2], 'after a deploy every open tab is older until it moves');
++ const realNow = Date.now;
++ try { Date.now = () => realNow() + 13 * 60 * 1000; assert.deepStrictEqual([gauge('current'), gauge('older')], [0, 0], 'a tab not heard from in 12 minutes is gone'); } finally { Date.now = realNow; }
++ }
++
// mount(): GET /release.json and POST /release-metrics on a real app, behind express.json(), fed by
// the text/plain body navigator.sendBeacon sends; the manifest then says where to report.
{
@@@ -207,33 +235,33 @@ const valid11 = (m) => { const ok = validate11(m); return ok || JSON.stringify(v
assert.ok(!p.document.querySelector('script[src*="release-update.js"]'), 'a manifest without components needs no release-update.js');
p.toasts[0].o.action.onClick();
assert.strictEqual(p.reloads, 1, 'Reload reloads');
-- assert.deepStrictEqual(p.beacons.at(-1).body.counts, { reloaded: { user: 1 } }, 'and is counted before the page goes');
++ assert.deepStrictEqual(p.beacons.map((x) => x.body.counts), [{ prompted: { optional: 1 } }, { reloaded: { user: 1 } }], 'the prompt is counted when shown, the reload before the page goes');
p = await tab({ server: 'bbbbbbb', releasedAt: stale });
assert.strictEqual(p.reloads, 1, 'outside the window and hidden: reloads');
-- assert.deepStrictEqual(p.beacons[0].body.counts, { reloaded: { window: 1 } });
++ assert.deepStrictEqual(p.beacons[0].body.counts, { prompted: { window: 1 }, reloaded: { window: 1 } });
assert.strictEqual(p.beacons[0].to, '/release-metrics');
p = await tab({ server: 'bbbbbbb', min: 'bbbbbbb' });
assert.strictEqual(p.reloads, 1, 'server requires the new release: reloads when safe');
-- assert.deepStrictEqual(p.metrics(), { reloaded: { required: 1 } });
++ assert.deepStrictEqual(p.metrics(), { prompted: { required: 1 }, reloaded: { required: 1 } });
p = await tab({ server: 'bbbbbbb', releasedAt: stale, html: HTML('', '<textarea></textarea>'), focus: 'textarea' });
assert.strictEqual(p.reloads, 0, 'never while a text field has focus');
p.tick(); p.tick();
-- assert.deepStrictEqual(p.metrics(), { deferred: { typing: 1 } }, 'a deferral is counted once per release and reason');
++ assert.deepStrictEqual(p.metrics(), { prompted: { window: 1 }, deferred: { typing: 1 } }, 'a deferral is counted once per release and reason');
p.blur(); p.tick();
assert.strictEqual(p.reloads, 1, 'and reloads once the field lets go');
p = await tab({ server: 'bbbbbbb', releasedAt: stale, protectedFn: () => true });
assert.strictEqual(p.reloads, 0, 'never during a protected session (upload, call, broadcast)');
-- assert.deepStrictEqual(p.metrics(), { deferred: { protected: 1 } });
++ assert.deepStrictEqual(p.metrics(), { prompted: { window: 1 }, deferred: { protected: 1 } });
p = await tab({ server: 'bbbbbbb', releasedAt: stale, html: HTML('', '<form data-dirty="true"></form>') });
-- assert.deepStrictEqual([p.reloads, p.metrics()], [0, { deferred: { dirty: 1 } }], 'never with unsent form input');
++ assert.deepStrictEqual([p.reloads, p.metrics()], [0, { prompted: { window: 1 }, deferred: { dirty: 1 } }], 'never with unsent form input');
p = await tab({ server: 'bbbbbbb', releasedAt: stale, html: HTML('', '<video></video>') });
-- assert.deepStrictEqual([p.reloads, p.metrics()], [0, { deferred: { media: 1 } }], 'never while a stream is playing (a linkedom <video> is not paused)');
++ assert.deepStrictEqual([p.reloads, p.metrics()], [0, { prompted: { window: 1 }, deferred: { media: 1 } }], 'never while a stream is playing (a linkedom <video> is not paused)');
{
// A paused <video> with a live camera track is a capture: served stale, it waits.
const html = HTML('', '<video></video>');
@@@ -242,14 +270,14 @@ const valid11 = (m) => { const ok = validate11(m); return ok || JSON.stringify(v
const vid = page.document.querySelector('video');
vid.paused = true; vid.srcObject = { getTracks: () => [{ readyState: 'live' }] };
await page.settle();
-- assert.deepStrictEqual([page.reloads, page.metrics()], [0, { deferred: { capture: 1 } }], 'never while the camera/mic is live');
++ assert.deepStrictEqual([page.reloads, page.metrics()], [0, { prompted: { window: 1 }, deferred: { capture: 1 } }], 'never while the camera/mic is live');
}
p = await tab({ server: 'bbbbbbb', releasedAt: stale, hidden: false });
-- assert.deepStrictEqual([p.reloads, p.metrics()], [0, { deferred: { active: 1 } }], 'a visible tab someone just used is not reloaded');
++ assert.deepStrictEqual([p.reloads, p.metrics()], [0, { prompted: { window: 1 }, deferred: { active: 1 } }], 'a visible tab someone just used is not reloaded');
p.setHidden(true);
assert.strictEqual(p.reloads, 1, 'hiding it is the safe moment');
-- assert.deepStrictEqual(p.beacons.map((b) => b.body.counts), [{ deferred: { active: 1 } }, { reloaded: { window: 1 } }], 'hiding sends the deferral, the reload its own count');
++ assert.deepStrictEqual(p.beacons.map((b) => b.body.counts), [{ prompted: { window: 1 }, deferred: { active: 1 } }, { reloaded: { window: 1 } }], 'hiding sends the prompt and the deferral, the reload its own count');
// Without a meta tag the first /release.json read is the baseline; a later release prompts.
{
@@@ -277,5 +305,26 @@ const valid11 = (m) => { const ok = validate11(m); return ok || JSON.stringify(v
assert.strictEqual(page.beacons.length, 0);
}
++ // The tab beats: once when its manifest is read, then every 5 minutes, and says when it ends.
++ {
++ let now = Date.now();
++ class FakeDate extends Date { static now() { return now; } }
++ const manifest = { service: 'test', release: 'aaaaaaa', released_at: fresh, min_client_release: null, mixed_version_window_hours: 24 };
++ const page = await openPage({ url: 'https://site.test/', html: HTML(), hidden: true, config: { metricsUrl: '/release-metrics' },
++ globals: { crypto: require('crypto').webcrypto, Date: FakeDate }, serve: () => ({ json: manifest }) });
++ await page.settle();
++ assert.strictEqual(page.beacons.length, 1, 'one beat on load');
++ const b = page.beacons[0].body;
++ assert.match(b.session, /^[0-9a-f]{16}$/);
++ assert.deepStrictEqual([b.release, b.counts, b.ended], ['aaaaaaa', undefined, undefined], 'the release it runs, nothing counted');
++ page.tick(); await page.settle();
++ assert.strictEqual(page.beacons.length, 1, 'no second beat within 5 minutes');
++ now += 5 * 60 * 1000; page.tick(); await page.settle();
++ assert.strictEqual(page.beacons.length, 2, 'a beat every 5 minutes');
++ assert.strictEqual(page.beacons[1].body.session, b.session, 'the same tab id');
++ page.window.dispatchEvent(new page.window.Event('pagehide'));
++ assert.deepStrictEqual([page.beacons.length, page.beacons[2].body.ended, page.beacons[2].body.session], [3, true, b.session], 'pagehide says it ended');
++ }
++
console.log('release: all checks passed');
})().catch((e) => { console.error(e); process.exit(1); });