openvibers/openvibe.shared Commits
v1.23.1: two release-watch fixes found by the release-lifecycle acceptance scenarios (D46, roadmap WS-P task 16; OpenVibe.Host scripts/release-acceptance.js), and tests for scenarios 4, 9 and 14.
Changes
@@@ -4,6 +4,14 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semvebreaking 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.23.1 — 2026-09-27++++**Two release-watch fixes found by the release-lifecycle acceptance scenarios** (D46, roadmap WS-P task 16; OpenVibe.Host `scripts/release-acceptance.js`). release-watch.js stays within its 5 KB brotli budget (4.99 KB; comments shortened).++- **An account switch during an update** (scenario 9). An in-place update fetches its regions with the session (`credentials: 'same-origin'`), so a region can be rendered for whoever is signed in. On sites that switch accounts in place (Live: the `openvibe-auth-changed` window event), a region fetched for the previous account and still waiting for focus to leave it, or still in flight when the account changed, was committed as fetched: the new account (or a guest, after signing out) saw what the server had rendered for the previous one. Now `openvibe-auth-changed` drops such regions and fetches them again with the new session, and an answer that arrives after a switch is never committed (counted as `deferred: account`, a new reason `/release-metrics` accepts). The switch itself still reloads nothing, prompts nothing and opens no second stream.++- **Resuming an offline tab** (scenario 14). Back online, the release stream reconnected at once only when it had given up (6 failures); a tab that came back while still backing off waited out the backoff (up to 8 minutes) before hearing release notifications again. `online` now reconnects at once from the cursor in both cases, like `notification-live.js` does.++++New tests: `test/release-account-switch.test.js` and `test/release-offline-resume.test.js` (offline: no prompt, no reload, nothing counted as failed; back online: one `/release.json` read straight to the newest release, in place; a connection lost mid-update leaves the page whole and prompts once; past the window, never reloaded under a focused field; a hidden tab resumes its stream when shown). And `test/release-reading.test.js` (scenario 4, a feed update while reading: a region above the viewport that grows or shrinks moves the page by exactly that, the region keeps its own scroll, a region in view is not compensated; nothing to fix). All three print `[metric] name=value` lines the acceptance runner reads.++## 1.23.0 — 2026-09-27**Web runtime** (roadmap WS-P task 6). New browser file `web-runtime.js` (`OVWebRuntime.create(options)`), in `files.js` and exported as `openvibe-shared/web-runtime`: OpenVibe.Live's feature loader (`public/js/ov-loader.js`), made shareable and extended. 6.1 KB brotli, budget 6.5 KB.@@@ -208,6 +208,7 @@ release.mount(app, { registry: m.registry }); // GET /release.json, POST /rele| Content (`content`) | re-fetches the region, skips an unchanged `data-ov-rev`, keeps its scroll and what the reader sees above it | `ov:content-updated`; `applied` || A widget inside a region | told to let go before its region is replaced, then to mount again | `ov:content-dispose`, then `ov:content-updated` || A busy region (focus, unsent form, playing media, live camera/mic, `data-ov-protected`) | waits, and is replaced once it is not busy | `deferred` with the reason, once per release |++ | An account switched in place (the page's `openvibe-auth-changed` window event) while a region waits or is being fetched | the region is fetched again with the new session; what was fetched for the previous account is never committed (test/release-account-switch.test.js) | `deferred: account` || Shell, router or any script (`script` and anything not in place) | prompts once; reloads only when it must | `prompted: optional` || Security minimum (`min_client_release`), mixed-version window over, contracts out of range | reloads when safe (hidden or idle, nothing busy), prompting meanwhile | `prompted` and `reloaded: required/window/contract` || Server only (`server`) | nothing to do in the tab | `applied: server` |@@@ -221,11 +222,14 @@ release.mount(app, { registry: m.registry }); // GET /release.json, POST /relecheck once, after a random 0–20 s delay.- **Bursts.** At most one check per 30 s; anything that arrives in between collapses into one more.- **Account changes.** The events are public, so an account switch changes nothing and never opens a-- second stream.++ second stream. `/release.json` is read without credentials, so whether a tab updates never depends on++ who is signed in.- **Hidden tabs.** A tab hidden for 5 minutes closes its stream, and reopens it on return with`last_event_id`.- **Failures.** Errors back off from 30 s to 15 minutes. After 6 failures in a row only the poll is left,-- until the browser goes back online.++ until the browser goes back online. Back online (the `online` event), the stream reconnects at once from++ its cursor, whether it had given up or was still backing off, and one check runs++ (test/release-offline-resume.test.js).- **Where to connect.** The default applies on https pages only. `OVReleaseConfig.eventsUrl` or the metatag's `data-events` sets another URL, and `false` or `"off"` turns it off. Polling (focus, visibility,every 10 minutes) is unchanged.@@@ -4,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "1.23.0",++ "version": "1.23.1","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)","updated": "2026-09-27"}@@@ -1,12 +1,12 @@{"name": "openvibe-shared",-- "version": "1.23.0",++ "version": "1.23.1","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-shared",-- "version": "1.23.0",++ "version": "1.23.1","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0"@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "1.23.0",++ "version": "1.23.1","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","exports": {@@@ -6,9 +6,6 @@* min_client_generation, the mixed-version window, contracts) and only when safe: hidden or idle 2 min, no* focused field, nothing protected (form[data-dirty="true"], [data-ov-protected], playing media, a live* camera/mic, OVProtected()). Outcomes and a 5-minute beat go to the metrics URL (D46).-- * Release notifications (WS-P task 9): one anonymous EventSource on Events' realtime stream-- * (host.release.published); a new release of this service runs check(true) after a 0-20 s jitter, at-- * most every 30 s. Polling stays the fallback.* OVReleaseConfig: { url, metricsUrl, updateUrl, inPlace: false, service, eventsUrl (false: off) }; the meta* tag may carry data-service, data-events and data-generation. See README "Releases".*/@@@ -31,7 +28,7 @@let prompted = false;let mustReload = false; let reloadWhy = null;let busyWith = null; // the plan/apply in flight-- let pending = null; let waiting = []; let kinds = ''; let failedRelease = null; let lib = null;++ let pending = null; let waiting = []; let kinds = ''; let failedRelease = null; let lib = null; let acct = 0;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 */ }@@@ -126,6 +123,7 @@}async function decide(m) {++ const who = acct;let x = null;if (m.components || m.contract_ranges) { try { x = await loadUpdate(); } catch { record('failed', 'script'); flush(); } }const page = base || { release: current, client_generation: gen };@@@ -140,7 +138,9 @@}try {kinds = Array.from(new Set(p.changed.map((c) => c.kind))).sort().join('+');-- waiting = await x.apply(p, m, busy, record); pending = m;++ // An account switch meanwhile (openvibe-auth-changed): fetch the regions again.++ waiting = await x.apply(p, m, (s) => (who !== acct ? 'account' : busy(s)), record); pending = m;++ if (who !== acct) { waiting = []; pending = null; return decide(m); }if (!waiting.length) adopt(m);} catch (why) {waiting = []; pending = null;@@@ -176,9 +176,9 @@return m;}-- // ── 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.++ // ── Release notifications (host.release.published, public, via OpenVibe.Events; WS-P task 9) ──++ // One credential-less EventSource per tab. 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; online reconnects at once.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 };@@@ -220,7 +220,7 @@later('retry', () => { if (document.hidden) rt.state = 'hidden'; else open(); }, Math.min(9e5, 3e4 * 2 ** (rt.failures - 1)) * (0.5 + Math.random() / 2));}function hide() { if (document.hidden && es) { close(); rt.state = 'hidden'; } }-- // A Content-Security-Policy whose connect-src leaves Events out: one refusal, then polling only.++ // A CSP whose connect-src leaves Events out: one refusal, then polling only.document.addEventListener('securitypolicyviolation', (e) => {try { if (!rt.url || String(e.blockedURI || '').indexOf(new URL(rt.url, root.location.href).origin) !== 0) return; } catch { return; }close(); root.clearTimeout(t.retry); t.retry = null; rt.state = 'blocked';@@@ -253,7 +253,8 @@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('online', () => { if (stopped) return; if (rt.state === 'failed' || rt.state === 'backoff') { root.clearTimeout(t.retry); rt.state = 'off'; rt.failures = 0; live(); } check(true); });++ root.addEventListener('openvibe-auth-changed', () => { acct++; if (!stopped && pending && !busyWith) { waiting = []; pending = null; consider(latest); } });root.addEventListener('pagehide', () => flush(true, true));document.addEventListener('visibilitychange', () => {if (stopped) return;@@@ -213,7 +213,7 @@ 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)$/,++ deferred: /^(typing|dirty|protected|media|capture|active|account)$/,failed: /^(style|style-timeout|content|origin|script)$/,};const MAX_BODY = 4096;nnew file mode 100644@@@ -0,0 +1,168 @@++'use strict';++// An account switch during an update (D46 scenario 9), release-watch.js + release-update.js in a linkedom page.++// Sites like Live switch accounts in place: the page stays, the session cookie changes and the app announces++// it with the `openvibe-auth-changed` window event. An in-place update fetches its regions with the session++// (credentials: same-origin), so what a region holds can depend on who is signed in. Checked:++// - /release.json is always read without credentials: whether a tab updates never depends on the account;++// - a region fetched for the previous account and still waiting (focus inside it) is fetched again for the++// new one, never committed as it was;++// - a region whose fetch was in flight when the account changed is fetched again, not committed;++// - the switch itself reloads nothing, prompts nothing, opens no second release stream, and a switch with no++// update pending fetches nothing;++// - a required reload deferred by typing is not hurried by a switch.++// Lines `[metric] name=value` are read by OpenVibe.Host's release-acceptance runner.++const assert = require('assert');++const { openPage, manifestFor } = require('../release-compat');++++const URL_ = 'https://site.test/docs/a';++const A = 'aaaaaaa'; const B = 'bbbbbbb';++const comps = (o) => ({ styles: { kind: 'style', version: 'css1' }, docs: { kind: 'content', version: 'd1' }, server: { kind: 'server', version: 'r1' }, ...o });++const assets = { '/css/app.css': { url: '/css/app.css?v=111111111111', component: 'styles' } };++const M1 = manifestFor({ service: 'test', release: A, components: comps(), assets });++const M2 = manifestFor({ service: 'test', release: B, components: comps({ docs: { kind: 'content', version: 'd2' }, server: { kind: 'server', version: 'r2' } }), assets });++++const PAGE = (extra = '') => `<!doctype html><html><head>++<meta name="ov-release" content="${A}" data-url="/release.json">++<link rel="stylesheet" href="/css/app.css?v=111111111111">++</head><body><main data-ov-content="docs" data-ov-rev="1"><p>old text for alice</p>${extra}</main></body></html>`;++// The server renders the region for whoever the request's cookie names (none: a guest).++const REGION = (who) => `<!doctype html><html><body><main data-ov-content="docs" data-ov-rev="2"><p>new text for ${who || 'a guest'}</p></main></body></html>`;++++function fakeEventSource() {++ const all = [];++ class FakeEventSource {++ constructor(url) { this.url = url; this.closed = false; all.push(this); }++ addEventListener() {}++ close() { this.closed = true; }++ }++ return { FakeEventSource, all };++}++++async function open({ html = PAGE(), served = M1, hidden = true, hold = false } = {}) {++ const es = fakeEventSource();++ const state = { served, account: 'alice', held: [], requests: [] };++ const p = await openPage({++ url: URL_, html, hidden, globals: { EventSource: es.FakeEventSource },++ config: { eventsUrl: 'https://events.test/realtime/stream', service: 'test' },++ serve: (u, init) => {++ const { pathname } = new URL(u);++ // The cookie goes along unless the request omits credentials.++ const who = init && init.credentials === 'omit' ? null : state.account;++ state.requests.push({ pathname, credentials: init && init.credentials, who });++ if (pathname === '/release.json') return { json: state.served };++ if (pathname === '/docs/a') {++ if (hold) return new Promise((resolve) => state.held.push(() => resolve({ body: REGION(who) })));++ return { body: REGION(who) };++ }++ return { status: 404 };++ },++ });++ await p.settle();++ p.state = state; p.es = es;++ p.switchTo = async (who) => { state.account = who; p.dispatch('openvibe-auth-changed'); await p.settle(); };++ p.regionFetches = () => state.requests.filter((r) => r.pathname === '/docs/a');++ p.text = () => p.document.querySelector('main').textContent.trim();++ return p;++}++++const metrics = { 'account-switch.stale-regions-committed': 0, 'account-switch.reloads': 0, 'account-switch.prompts': 0, 'account-switch.release-streams': 0, 'account-switch.manifest-reads-with-credentials': 0 };++const printMetrics = () => { for (const [k, v] of Object.entries(metrics)) console.log(`[metric] ${k}=${v}`); };++const note = (p) => {++ if (/alice/.test(p.text()) && p.release.current === B) metrics['account-switch.stale-regions-committed']++;++ metrics['account-switch.reloads'] += p.reloads;++ metrics['account-switch.prompts'] += p.toasts.length;++ metrics['account-switch.release-streams'] = Math.max(metrics['account-switch.release-streams'], p.es.all.filter((s) => !s.closed).length);++ metrics['account-switch.manifest-reads-with-credentials'] += p.state.requests.filter((r) => r.pathname === '/release.json' && r.credentials !== 'omit').length;++};++++(async () => {++ // ── A region waiting (focus inside it) when the account changes: fetched again for the new account ──++ {++ const p = await open({ html: PAGE('<input>') });++ p.focus(p.document.querySelector('main input'));++ p.state.served = M2;++ await p.release.check(); await p.settle();++ assert.strictEqual(p.release.state().waiting, 1, 'the focused region waits');++ assert.deepStrictEqual(p.regionFetches().map((r) => r.who), ['alice'], 'fetched once, with alice\'s session');++ await p.switchTo('bob');++ assert.deepStrictEqual([p.reloads, p.toasts.length], [0, 0], 'the switch reloads nothing and prompts nothing');++ assert.strictEqual(p.es.all.length, 1, 'still one release stream');++ p.blur(); p.tick(); await p.settle();++ note(p);++ assert.match(p.text(), /new text for bob/, 'the region committed is the one rendered for the account now signed in');++ assert.ok(!/alice/.test(p.text()), 'nothing rendered for alice reaches the page after the switch');++ assert.deepStrictEqual(p.regionFetches().map((r) => r.who), ['alice', 'bob'], 'fetched again for bob, once');++ assert.strictEqual(p.release.current, B, 'and the release is adopted');++ assert.strictEqual(p.release.state().waiting, 0);++ p.stop();++ }++++ // ── The region fetch is in flight when the account changes: that answer is not committed ──++ {++ const p = await open({ hold: true });++ p.state.served = M2;++ const done = p.release.check();++ await p.settle();++ assert.deepStrictEqual(p.regionFetches().map((r) => r.who), ['alice'], 'the region fetch started with alice\'s session');++ await p.switchTo('bob');++ p.state.held.shift()(); // alice's answer arrives after the switch++ await p.settle();++ // The answer for bob is fetched and arrives too.++ while (p.state.held.length) { p.state.held.shift()(); await p.settle(); }++ await done; await p.settle();++ note(p);++ assert.match(p.text(), /new text for bob/, 'the page shows what the server renders for bob');++ assert.deepStrictEqual(p.regionFetches().map((r) => r.who), ['alice', 'bob']);++ assert.strictEqual(p.release.current, B);++ assert.deepStrictEqual([p.reloads, p.toasts.length], [0, 0]);++ assert.deepStrictEqual(p.metrics(), { deferred: { account: 1 }, applied: { 'content+server': 1 } }, 'counted: the stale answer as deferred (account), then the update');++ p.stop();++ }++++ // ── Signing out is an account change too: a guest never sees the signed-in rendering ──++ {++ const p = await open({ html: PAGE('<input>') });++ p.focus(p.document.querySelector('main input'));++ p.state.served = M2;++ await p.release.check(); await p.settle();++ await p.switchTo(null);++ p.blur(); p.tick(); await p.settle();++ note(p);++ assert.match(p.text(), /new text for a guest/);++ p.stop();++ }++++ // ── Nothing pending: a switch fetches nothing and changes nothing ──++ {++ const p = await open();++ const before = p.state.requests.length;++ await p.switchTo('bob');++ note(p);++ assert.strictEqual(p.state.requests.length, before, 'no request');++ assert.deepStrictEqual([p.reloads, p.toasts.length, p.es.all.length, p.release.current], [0, 0, 1, A]);++ p.stop();++ }++++ // ── A required reload deferred by typing is not hurried by a switch ──++ {++ const p = await open({ html: PAGE('<textarea></textarea>'), hidden: false });++ p.focus(p.document.querySelector('textarea'));++ p.state.served = { ...M2, components: comps({ shell: { kind: 'script', version: 's2' } }), min_client_release: B };++ await p.release.check(); await p.settle();++ assert.deepStrictEqual([p.reloads, p.toasts.length], [0, 1], 'required: prompted, not reloaded under the person');++ await p.switchTo('bob');++ p.tick(); await p.settle();++ metrics['account-switch.reloads'] += p.reloads;++ assert.strictEqual(p.reloads, 0, 'not while the text field has focus, switch or not');++ p.blur(); p.setHidden(true);++ assert.strictEqual(p.reloads, 1, 'reloaded at the next safe moment');++ p.stop();++ }++++ // The update decision is the same for every account: /release.json never carries the session.++ assert.strictEqual(metrics['account-switch.manifest-reads-with-credentials'], 0);++ assert.strictEqual(metrics['account-switch.stale-regions-committed'], 0);++ assert.strictEqual(metrics['account-switch.release-streams'], 1);++ printMetrics();++ console.log('release account switch: all checks passed');++})().catch((e) => { printMetrics(); console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,197 @@++'use strict';++// Resuming an offline tab (D46 scenario 14), release-watch.js + release-update.js in a linkedom page with a++// fake clock and a fake EventSource. A laptop lid closes or the network drops; releases ship meanwhile; the++// tab comes back. Checked:++// - offline, the checks fail quietly: no prompt, no reload, no failure counted, the release stream backs off;++// - back online (the `online` event), the release stream reconnects at once from its cursor, whether it had++// given up or was still backing off, and one /release.json read brings the tab to the newest release,++// skipping the ones it missed, in place when the change allows;++// - a connection lost in the middle of an in-place update leaves the page whole (old styles, old region)++// and prompts once; coming back does not prompt again;++// - a tab away past the mixed-version window reloads only when safe (never under a focused field);++// - a hidden tab keeps its stream closed until it is shown again, then resumes from the cursor.++// Lines `[metric] name=value` are read by OpenVibe.Host's release-acceptance runner.++const assert = require('assert');++const { openPage, manifestFor } = require('../release-compat');++++const URL_ = 'https://site.test/docs/a';++const A = 'aaaaaaa'; const B = 'bbbbbbb'; const C = 'ccccccc';++const comps = (o) => ({ styles: { kind: 'style', version: 'css1' }, docs: { kind: 'content', version: 'd1' }, shell: { kind: 'script', version: 'sh1' }, server: { kind: 'server', version: 'r1' }, ...o });++const assets = (v) => ({ '/css/app.css': { url: `/css/app.css?v=${v}`, component: 'styles' } });++const M = (release, o = {}, css = '111111111111', releasedAt) => manifestFor({ service: 'test', release, components: comps(o), assets: assets(css), releasedAt, metricsUrl: '/release-metrics' });++const M1 = M(A);++const MB = M(B, { docs: { kind: 'content', version: 'd2' } });++const MC = M(C, { docs: { kind: 'content', version: 'd3' }, styles: { kind: 'style', version: 'css3' } }, '333333333333');++++const PAGE = (extra = '') => `<!doctype html><html><head>++<meta name="ov-release" content="${A}" data-url="/release.json">++<link rel="stylesheet" href="/css/app.css?v=111111111111">++</head><body><main data-ov-content="docs" data-ov-rev="1"><p>old text</p>${extra}</main></body></html>`;++const REGION = (rev) => `<!doctype html><html><body><main data-ov-content="docs" data-ov-rev="${rev}"><p>text of ${rev}</p></main></body></html>`;++++function fakeEventSource() {++ const all = [];++ class FakeEventSource {++ constructor(url) { this.url = url; this.closed = false; this.listeners = {}; all.push(this); }++ addEventListener(type, f) { (this.listeners[type] = this.listeners[type] || []).push(f); }++ close() { this.closed = true; }++ opened() { if (this.onopen) this.onopen({}); }++ send(seq, event) { if (this.onmessage) this.onmessage({ data: JSON.stringify({ seq, event }), lastEventId: String(seq) }); }++ fail() { if (this.onerror) this.onerror({}); }++ }++ return { FakeEventSource, all };++}++const released = (service, release, n) => ({++ event_id: `evt_01JAB2C3D4E5F6G7H8J9K0M${String(n).padStart(3, '0')}`, event_type: 'host.release.published', version: 1, source: 'host',++ visibility: 'public', subject: { type: 'release', id: `${service}:${release}` }, payload: { service, release, commit: null, deployed_at: '2026-09-27T08:00:00.000Z' },++});++++async function open({ html = PAGE(), served = M1, hidden = true } = {}) {++ const clock = { now: Date.parse('2026-09-27T10:00:00Z') };++ class FakeDate extends Date {++ constructor(...a) { super(...(a.length ? a : [clock.now])); }++ static now() { return clock.now; }++ }++ const es = fakeEventSource();++ const state = { served, offline: false, failNext: false, requests: [] };++ const p = await openPage({++ url: URL_, html, hidden,++ config: { eventsUrl: 'https://events.test/realtime/stream', service: 'test' },++ globals: { EventSource: es.FakeEventSource, Date: FakeDate, Math: Object.assign(Object.create(Math), { random: () => 0 }) },++ serve: (u) => {++ const { pathname } = new URL(u);++ state.requests.push({ pathname, at: clock.now, offline: state.offline });++ if (state.offline) throw new TypeError('Failed to fetch');++ if (pathname === '/release.json') return { json: state.served };++ if (pathname === '/docs/a') {++ if (state.failNext) { state.failNext = false; throw new TypeError('Failed to fetch'); }++ return { body: REGION(state.served.components.docs.version) };++ }++ return { status: 404 };++ },++ });++ await p.settle();++ p.state = state; p.es = es; p.clock = clock;++ p.later = (ms) => { clock.now += ms; };++ p.manifestReads = () => state.requests.filter((r) => r.pathname === '/release.json' && !r.offline).length;++ p.rt = () => p.release.state().realtime;++ p.text = () => p.document.querySelector('main').textContent.trim();++ p.css = () => Array.from(p.document.querySelectorAll('link[rel~="stylesheet"]')).map((l) => l.getAttribute('href'));++ return p;++}++++const printMetrics = () => { for (const [k, v] of Object.entries(metrics)) console.log(`[metric] ${k}=${v}`); };++const metrics = { 'offline-resume.reloads-while-offline': 0, 'offline-resume.prompts-while-offline': 0, 'offline-resume.failures-counted-offline': 0, 'offline-resume.stream-reconnect-delay-ms': 0, 'offline-resume.manifest-reads-on-resume': 0, 'offline-resume.partial-pages': 0, 'offline-resume.reloads-while-typing': 0 };++++(async () => {++ // ── Offline while two releases ship, the stream still backing off; then back online ──++ for (const failures of [3, 6]) {++ const p = await open({ hidden: false });++ const s0 = p.es.all[0];++ s0.opened();++ s0.send(7, released('other', B, 1));++ assert.strictEqual(p.rt().lastSeq, 7);++ // The network drops.++ p.state.offline = true;++ s0.fail();++ let retrySetAt = p.clock.now;++ for (let i = 1; i < failures; i++) {++ assert.strictEqual(p.rt().state, 'backoff');++ const wait = Math.min(...p.timeouts());++ p.later(wait); p.fireTimeouts((ms) => ms === wait); await p.settle();++ p.es.all[p.es.all.length - 1].fail();++ retrySetAt = p.clock.now;++ }++ assert.strictEqual(p.rt().state, failures >= 6 ? 'failed' : 'backoff', `${failures} failures`);++ // Releases ship while it is away (30 s more, less than any pending backoff); its checks fail quietly.++ p.state.served = MB;++ p.later(30 * 1000); p.tick(); await p.settle();++ p.dispatch('focus'); await p.release.check(); await p.settle();++ p.state.served = MC;++ assert.ok(p.state.requests.some((r) => r.offline && r.pathname === '/release.json'), 'it did try');++ metrics['offline-resume.reloads-while-offline'] += p.reloads;++ metrics['offline-resume.prompts-while-offline'] += p.toasts.length;++ metrics['offline-resume.failures-counted-offline'] += Object.values(p.metrics().failed || {}).reduce((a, b) => a + b, 0);++ assert.deepStrictEqual([p.reloads, p.toasts.length, p.metrics(), p.release.current], [0, 0, {}, A], 'offline: no prompt, no reload, nothing counted as failed');++++ // Back online.++ p.state.offline = false;++ const streams = p.es.all.length; const reads = p.manifestReads();++ const retry = p.timeouts().length ? Math.min(...p.timeouts()) - (p.clock.now - retrySetAt) : 0; // what is left of the backoff++ p.dispatch('online'); await p.settle();++ const reconnected = p.es.all.length > streams;++ // Not reconnected at once: the stream waits out its backoff, and that wait is the delay.++ if (!reconnected) metrics['offline-resume.stream-reconnect-delay-ms'] = Math.max(metrics['offline-resume.stream-reconnect-delay-ms'], retry);++ assert.ok(reconnected, `back online after ${failures} failures: the release stream reconnects at once`);++ assert.match(p.es.all[p.es.all.length - 1].url, /last_event_id=7$/, 'from its cursor');++ assert.strictEqual(p.rt().failures, 0);++ p.settleStyles(true); await p.settle();++ metrics['offline-resume.manifest-reads-on-resume'] = Math.max(metrics['offline-resume.manifest-reads-on-resume'], p.manifestReads() - reads);++ assert.strictEqual(p.manifestReads() - reads, 1, 'one /release.json read');++ assert.strictEqual(p.release.current, C, 'straight to the newest release, the missed one skipped');++ assert.match(p.text(), /text of d3/);++ assert.deepStrictEqual(p.css(), ['https://site.test/css/app.css?v=333333333333']);++ assert.deepStrictEqual([p.reloads, p.toasts.length, p.metrics()], [0, 0, { applied: { 'content+style': 1 } }], 'in place, nothing asked of the person');++ assert.deepStrictEqual(p.timeouts().filter((ms) => ms >= 15000), [], 'no retry left pending');++ // Focus and visibility right after: nothing more to read.++ p.dispatch('focus'); p.setHidden(false); await p.settle();++ assert.strictEqual(p.manifestReads() - reads, 1);++ p.stop();++ }++++ // ── The connection drops in the middle of an in-place update ──++ {++ const p = await open();++ p.state.served = MC;++ p.state.failNext = true;++ await p.release.check(); await p.settle();++ if (p.text() !== 'old text' || p.css().join() !== '/css/app.css?v=111111111111') metrics['offline-resume.partial-pages']++;++ assert.deepStrictEqual([p.text(), p.css()], ['old text', ['/css/app.css?v=111111111111']], 'the page is whole: old region, old styles');++ assert.deepStrictEqual([p.release.current, p.toasts.length, p.reloads, p.metrics()], [A, 1, 0, { failed: { content: 1 }, prompted: { optional: 1 } }], 'failed in place: prompted once');++ p.later(5 * 60 * 1000);++ p.dispatch('online'); await p.settle();++ p.tick(); await p.settle();++ assert.deepStrictEqual([p.toasts.length, p.reloads], [1, 0], 'coming back does not prompt again');++ p.stop();++ }++++ // ── Away past the mixed-version window, a text field focused ──++ {++ const p = await open({ html: PAGE('<textarea></textarea>'), hidden: false });++ p.focus(p.document.querySelector('textarea'));++ p.state.offline = true;++ p.later(30 * 3600 * 1000);++ p.state.served = M(B, { shell: { kind: 'script', version: 'sh2' } }, '111111111111', new Date(p.clock.now - 29 * 3600 * 1000).toISOString());++ p.tick(); await p.settle();++ p.state.offline = false;++ p.dispatch('online'); await p.settle();++ metrics['offline-resume.reloads-while-typing'] += p.reloads;++ assert.deepStrictEqual([p.reloads, p.toasts.length], [0, 1], 'outside the window: prompted, not reloaded under the person');++ p.tick(); await p.settle();++ metrics['offline-resume.reloads-while-typing'] += p.reloads;++ assert.deepStrictEqual(p.metrics(), { prompted: { window: 1 }, deferred: { typing: 1 } });++ p.blur(); p.setHidden(true);++ assert.strictEqual(p.reloads, 1, 'and reloads at the next safe moment');++ p.stop();++ }++++ // ── Hidden: the stream stays closed until the tab is shown, then resumes from the cursor ──++ {++ const p = await open({ hidden: true });++ const s0 = p.es.all[0];++ s0.opened(); s0.send(11, released('other', B, 2));++ p.fireTimeouts((ms) => ms === 300000);++ assert.deepStrictEqual([s0.closed, p.rt().state], [true, 'hidden'], 'hidden 5 minutes: closed');++ p.state.offline = true; p.later(3600e3); p.tick(); await p.settle();++ p.state.offline = false;++ p.dispatch('online'); await p.settle();++ assert.strictEqual(p.es.all.length, 1, 'online while hidden: no stream yet');++ p.setHidden(false); await p.settle();++ assert.strictEqual(p.es.all.length, 2, 'shown: one stream');++ assert.match(p.es.all[1].url, /last_event_id=11$/);++ p.stop();++ }++++ printMetrics();++ console.log('release offline resume: all checks passed');++})().catch((e) => { printMetrics(); console.error(e); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,88 @@++'use strict';++// A feed update while reading (D46 scenario 4), release-watch.js + release-update.js in a linkedom page.++// A content release changes a feed region while the person reads the article under it. Checked:++// - a feed region above the viewport that grows (or shrinks) is replaced and the page scrolls by exactly the++// change, so what the reader is looking at does not move;++// - a region the reader is inside keeps its own scroll position;++// - a region in view is replaced without scrolling the page;++// - none of it reloads or prompts.++// linkedom has no layout, so each region's box is scripted: its height depends on whether it holds the new text.++// Lines `[metric] name=value` are read by OpenVibe.Host's release-acceptance runner.++const assert = require('assert');++const { openPage, manifestFor } = require('../release-compat');++++const URL_ = 'https://site.test/feed';++const A = 'aaaaaaa'; const B = 'bbbbbbb';++const comps = (o) => ({ feed: { kind: 'content', version: 'f1' }, side: { kind: 'content', version: 's1' }, shell: { kind: 'script', version: 'sh1' }, ...o });++const M1 = manifestFor({ service: 'test', release: A, components: comps() });++const M2 = manifestFor({ service: 'test', release: B, components: comps({ feed: { kind: 'content', version: 'f2' }, side: { kind: 'content', version: 's2' } }) });++const PAGE = `<!doctype html><html><head><meta name="ov-release" content="${A}" data-url="/release.json"></head><body>++<section data-ov-content="feed" data-ov-rev="1"><p>old item</p></section>++<article><p>The article the person is reading.</p></article>++<aside data-ov-content="side" data-ov-rev="1"><p>old side</p></aside>++</body></html>`;++const FRESH = `<!doctype html><html><body>++<section data-ov-content="feed" data-ov-rev="2"><p>new item</p><p>new item 2</p></section>++<aside data-ov-content="side" data-ov-rev="2"><p>new side</p></aside>++</body></html>`;++++async function open({ feedTop, feedOld, feedNew, sideTop = 200 }) {++ const scrolled = []; const state = { served: M1 };++ const p = await openPage({++ url: URL_, html: PAGE, hidden: false,++ globals: { scrollBy: (x, y) => scrolled.push(y) },++ serve: (u) => {++ const { pathname } = new URL(u);++ if (pathname === '/release.json') return { json: state.served };++ if (pathname === '/feed') return { body: FRESH };++ return { status: 404 };++ },++ });++ const box = (el, top, oldH, newH) => {++ el.getBoundingClientRect = () => { const h = /new/.test(el.textContent) ? newH : oldH; return { top, bottom: top + h, height: h, left: 0, right: 800, width: 800 }; };++ };++ box(p.document.querySelector('section'), feedTop, feedOld, feedNew);++ box(p.document.querySelector('aside'), sideTop, 100, 180);++ await p.settle();++ p.state = state; p.scrolled = scrolled;++ return p;++}++++const metrics = { 'feed-reading.position-shift-px': 0, 'feed-reading.in-view-scrolls': 0, 'feed-reading.region-scroll-lost-px': 0, 'feed-reading.reloads': 0, 'feed-reading.prompts': 0 };++const printMetrics = () => { for (const [k, v] of Object.entries(metrics)) console.log(`[metric] ${k}=${v}`); };++++(async () => {++ // A feed above the viewport grows by 300 px, or shrinks by 200 px: the page scrolls by that much.++ for (const [oldH, newH] of [[600, 900], [600, 400]]) {++ const p = await open({ feedTop: -1000, feedOld: oldH, feedNew: newH });++ const feed = p.document.querySelector('section');++ feed.scrollTop = 25; // the feed box scrolled on its own too++ p.state.served = M2;++ await p.release.check(); await p.settle();++ const moved = (newH - oldH) - p.scrolled.reduce((a, b) => a + b, 0);++ metrics['feed-reading.position-shift-px'] = Math.max(metrics['feed-reading.position-shift-px'], Math.abs(moved));++ metrics['feed-reading.region-scroll-lost-px'] = Math.max(metrics['feed-reading.region-scroll-lost-px'], Math.abs(25 - feed.scrollTop));++ metrics['feed-reading.reloads'] += p.reloads; metrics['feed-reading.prompts'] += p.toasts.length;++ assert.match(feed.textContent, /new item 2/, 'the feed is updated');++ assert.strictEqual(p.release.current, B);++ assert.deepStrictEqual(p.scrolled, [newH - oldH], `the page scrolls by the feed's change (${newH - oldH} px), once`);++ assert.strictEqual(moved, 0, 'what the reader looks at does not move');++ assert.strictEqual(feed.scrollTop, 25, 'the feed keeps its own scroll');++ assert.deepStrictEqual([p.reloads, p.toasts.length], [0, 0]);++ p.stop();++ }++ // Everything in view (the feed at the top of the viewport, the aside below it): replaced, no scrolling.++ {++ const p = await open({ feedTop: 0, feedOld: 600, feedNew: 900 });++ p.state.served = M2;++ await p.release.check(); await p.settle();++ metrics['feed-reading.in-view-scrolls'] += p.scrolled.length;++ metrics['feed-reading.reloads'] += p.reloads; metrics['feed-reading.prompts'] += p.toasts.length;++ assert.match(p.document.querySelector('aside').textContent, /new side/);++ assert.deepStrictEqual(p.scrolled, [], 'a region in view is not compensated by the page');++ p.stop();++ }++ assert.strictEqual(metrics['feed-reading.position-shift-px'], 0);++ printMetrics();++ console.log('release reading: all checks passed');++})().catch((e) => { printMetrics(); console.error(e); process.exit(1); });@@@ -69,6 +69,7 @@ const counts = (p) => p.beacons.reduce((acc, b) => { for (const [o, rs] of Objec// 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.// 1.20.0: client generations (+0.05 KB) fit by a tighter header comment: 4.94 KB.++// 1.23.1: the account-switch refetch and the online reconnect (+0.12 KB) fit by shorter comments: 4.99 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)));@@@ -166,6 +166,8 @@ const valid11 = (m) => { const ok = validate11(m); return ok || JSON.stringify(vassert.strictEqual(counter('applied', 'content+style'), 1);assert.strictEqual(counter('deferred', 'typing'), 2);assert.strictEqual(counter('deferred', 'other'), 1, 'an unknown reason counts as other');++ assert.strictEqual(await post({ counts: { deferred: { account: 1 } } }, {}, '10.0.0.5'), 204);++ assert.strictEqual(counter('deferred', 'account'), 1, 'a region fetched again after an account switch (1.23.1)');assert.strictEqual(counter('failed', 'style'), 50, 'one report counts at most 50 per reason');assert.strictEqual(await post('not json', {}, '10.0.0.2'), 400);assert.strictEqual(await post({ counts: { reloaded: { user: 1 } } }, { 'sec-gpc': '1' }, '10.0.0.3'), 204);
@@ -4,6 +4,14 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -4,6 +4,14 @@ 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.
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).
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
+## 1.23.1 — 2026-09-27
+
+**Two release-watch fixes found by the release-lifecycle acceptance scenarios** (D46, roadmap WS-P task 16; OpenVibe.Host `scripts/release-acceptance.js`). release-watch.js stays within its 5 KB brotli budget (4.99 KB; comments shortened).
+- **An account switch during an update** (scenario 9). An in-place update fetches its regions with the session (`credentials: 'same-origin'`), so a region can be rendered for whoever is signed in. On sites that switch accounts in place (Live: the `openvibe-auth-changed` window event), a region fetched for the previous account and still waiting for focus to leave it, or still in flight when the account changed, was committed as fetched: the new account (or a guest, after signing out) saw what the server had rendered for the previous one. Now `openvibe-auth-changed` drops such regions and fetches them again with the new session, and an answer that arrives after a switch is never committed (counted as `deferred: account`, a new reason `/release-metrics` accepts). The switch itself still reloads nothing, prompts nothing and opens no second stream.
+- **Resuming an offline tab** (scenario 14). Back online, the release stream reconnected at once only when it had given up (6 failures); a tab that came back while still backing off waited out the backoff (up to 8 minutes) before hearing release notifications again. `online` now reconnects at once from the cursor in both cases, like `notification-live.js` does.
+
+New tests: `test/release-account-switch.test.js` and `test/release-offline-resume.test.js` (offline: no prompt, no reload, nothing counted as failed; back online: one `/release.json` read straight to the newest release, in place; a connection lost mid-update leaves the page whole and prompts once; past the window, never reloaded under a focused field; a hidden tab resumes its stream when shown). And `test/release-reading.test.js` (scenario 4, a feed update while reading: a region above the viewport that grows or shrinks moves the page by exactly that, the region keeps its own scroll, a region in view is not compensated; nothing to fix). All three print `[metric] name=value` lines the acceptance runner reads.
+
## 1.23.0 — 2026-09-27
## 1.23.0 — 2026-09-27
**Web runtime** (roadmap WS-P task 6). New browser file `web-runtime.js` (`OVWebRuntime.create(options)`), in `files.js` and exported as `openvibe-shared/web-runtime`: OpenVibe.Live's feature loader (`public/js/ov-loader.js`), made shareable and extended. 6.1 KB brotli, budget 6.5 KB.
**Web runtime** (roadmap WS-P task 6). New browser file `web-runtime.js` (`OVWebRuntime.create(options)`), in `files.js` and exported as `openvibe-shared/web-runtime`: OpenVibe.Live's feature loader (`public/js/ov-loader.js`), made shareable and extended. 6.1 KB brotli, budget 6.5 KB.
@@ -208,6 +208,7 @@ release.mount(app, { registry: m.registry }); // GET /release.json, POST /rele
@@ -208,6 +208,7 @@ release.mount(app, { registry: m.registry }); // GET /release.json, POST /rele
| Content (`content`) | re-fetches the region, skips an unchanged `data-ov-rev`, keeps its scroll and what the reader sees above it | `ov:content-updated`; `applied` |
| Content (`content`) | re-fetches the region, skips an unchanged `data-ov-rev`, keeps its scroll and what the reader sees above it | `ov:content-updated`; `applied` |
| A widget inside a region | told to let go before its region is replaced, then to mount again | `ov:content-dispose`, then `ov:content-updated` |
| A widget inside a region | told to let go before its region is replaced, then to mount again | `ov:content-dispose`, then `ov:content-updated` |
| A busy region (focus, unsent form, playing media, live camera/mic, `data-ov-protected`) | waits, and is replaced once it is not busy | `deferred` with the reason, once per release |
| A busy region (focus, unsent form, playing media, live camera/mic, `data-ov-protected`) | waits, and is replaced once it is not busy | `deferred` with the reason, once per release |
+ | An account switched in place (the page's `openvibe-auth-changed` window event) while a region waits or is being fetched | the region is fetched again with the new session; what was fetched for the previous account is never committed (test/release-account-switch.test.js) | `deferred: account` |
| Shell, router or any script (`script` and anything not in place) | prompts once; reloads only when it must | `prompted: optional` |
| Shell, router or any script (`script` and anything not in place) | prompts once; reloads only when it must | `prompted: optional` |
| Security minimum (`min_client_release`), mixed-version window over, contracts out of range | reloads when safe (hidden or idle, nothing busy), prompting meanwhile | `prompted` and `reloaded: required/window/contract` |
| Security minimum (`min_client_release`), mixed-version window over, contracts out of range | reloads when safe (hidden or idle, nothing busy), prompting meanwhile | `prompted` and `reloaded: required/window/contract` |
| Server only (`server`) | nothing to do in the tab | `applied: server` |
| Server only (`server`) | nothing to do in the tab | `applied: server` |
@@ -221,11 +222,14 @@ release.mount(app, { registry: m.registry }); // GET /release.json, POST /rele
@@ -221,11 +222,14 @@ release.mount(app, { registry: m.registry }); // GET /release.json, POST /rele
check once, after a random 0–20 s delay.
check once, after a random 0–20 s delay.
- **Bursts.** At most one check per 30 s; anything that arrives in between collapses into one more.
- **Bursts.** At most one check per 30 s; anything that arrives in between collapses into one more.
- **Account changes.** The events are public, so an account switch changes nothing and never opens a
- **Account changes.** The events are public, so an account switch changes nothing and never opens a
- second stream.
+ second stream. `/release.json` is read without credentials, so whether a tab updates never depends on
+ who is signed in.
- **Hidden tabs.** A tab hidden for 5 minutes closes its stream, and reopens it on return with
- **Hidden tabs.** A tab hidden for 5 minutes closes its stream, and reopens it on return with
`last_event_id`.
`last_event_id`.
- **Failures.** Errors back off from 30 s to 15 minutes. After 6 failures in a row only the poll is left,
- **Failures.** Errors back off from 30 s to 15 minutes. After 6 failures in a row only the poll is left,
- until the browser goes back online.
+ until the browser goes back online. Back online (the `online` event), the stream reconnects at once from
+ its cursor, whether it had given up or was still backing off, and one check runs
+ (test/release-offline-resume.test.js).
- **Where to connect.** The default applies on https pages only. `OVReleaseConfig.eventsUrl` or the meta
- **Where to connect.** The default applies on https pages only. `OVReleaseConfig.eventsUrl` or the meta
tag's `data-events` sets another URL, and `false` or `"off"` turns it off. Polling (focus, visibility,
tag's `data-events` sets another URL, and `false` or `"off"` turns it off. Polling (focus, visibility,
every 10 minutes) is unchanged.
every 10 minutes) is unchanged.
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "1.23.0",
+ "version": "1.23.1",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"updated": "2026-09-27"
"updated": "2026-09-27"
}
}
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "1.23.0",
+ "version": "1.23.1",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "1.23.0",
+ "version": "1.23.1",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "1.23.0",
+ "version": "1.23.1",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"exports": {
"exports": {
@@ -6,9 +6,6 @@
@@ -6,9 +6,6 @@
* min_client_generation, the mixed-version window, contracts) and only when safe: hidden or idle 2 min, no
* min_client_generation, the mixed-version window, contracts) and only when safe: hidden or idle 2 min, no
* focused field, nothing protected (form[data-dirty="true"], [data-ov-protected], playing media, a live
* focused field, nothing protected (form[data-dirty="true"], [data-ov-protected], playing media, a live
* camera/mic, OVProtected()). Outcomes and a 5-minute beat go to the metrics URL (D46).
* camera/mic, OVProtected()). Outcomes and a 5-minute beat go to the metrics URL (D46).
- * Release notifications (WS-P task 9): one anonymous EventSource on Events' realtime stream
- * (host.release.published); a new release of this service runs check(true) after a 0-20 s jitter, at
- * most every 30 s. Polling stays the fallback.
* OVReleaseConfig: { url, metricsUrl, updateUrl, inPlace: false, service, eventsUrl (false: off) }; the meta
* OVReleaseConfig: { url, metricsUrl, updateUrl, inPlace: false, service, eventsUrl (false: off) }; the meta
* tag may carry data-service, data-events and data-generation. See README "Releases".
* tag may carry data-service, data-events and data-generation. See README "Releases".
*/
*/
@@ -31,7 +28,7 @@
@@ -31,7 +28,7 @@
let prompted = false;
let prompted = false;
let mustReload = false; let reloadWhy = null;
let mustReload = false; let reloadWhy = null;
let busyWith = null; // the plan/apply in flight
let busyWith = null; // the plan/apply in flight
- let pending = null; let waiting = []; let kinds = ''; let failedRelease = null; let lib = null;
+ let pending = null; let waiting = []; let kinds = ''; let failedRelease = null; let lib = null; let acct = 0;
let counts = {}; const totals = {}; const noted = {};
let counts = {}; const totals = {}; const noted = {};
let session = null; let lastBeat = 0;
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 */ }
try { session = Array.from(root.crypto.getRandomValues(new Uint8Array(8)), (b) => (b | 256).toString(16).slice(1)).join(''); } catch { /* no beats */ }
@@ -126,6 +123,7 @@
@@ -126,6 +123,7 @@
}
}
async function decide(m) {
async function decide(m) {
+ const who = acct;
let x = null;
let x = null;
if (m.components || m.contract_ranges) { try { x = await loadUpdate(); } catch { record('failed', 'script'); flush(); } }
if (m.components || m.contract_ranges) { try { x = await loadUpdate(); } catch { record('failed', 'script'); flush(); } }
const page = base || { release: current, client_generation: gen };
const page = base || { release: current, client_generation: gen };
@@ -140,7 +138,9 @@
@@ -140,7 +138,9 @@
}
}
try {
try {
kinds = Array.from(new Set(p.changed.map((c) => c.kind))).sort().join('+');
kinds = Array.from(new Set(p.changed.map((c) => c.kind))).sort().join('+');
- waiting = await x.apply(p, m, busy, record); pending = m;
+ // An account switch meanwhile (openvibe-auth-changed): fetch the regions again.
+ waiting = await x.apply(p, m, (s) => (who !== acct ? 'account' : busy(s)), record); pending = m;
+ if (who !== acct) { waiting = []; pending = null; return decide(m); }
if (!waiting.length) adopt(m);
if (!waiting.length) adopt(m);
} catch (why) {
} catch (why) {
waiting = []; pending = null;
waiting = []; pending = null;
@@ -176,9 +176,9 @@
@@ -176,9 +176,9 @@
return m;
return m;
}
}
- // ── 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.
+ // ── Release notifications (host.release.published, public, via OpenVibe.Events; WS-P task 9) ──
+ // One credential-less EventSource per tab. 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; online reconnects at once.
const TOPIC = 'host.release.published';
const TOPIC = 'host.release.published';
const HEX = /^[0-9a-f]{7,40}$/;
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 };
const rt = { state: 'off', service: null, url: null, events: 0, ignored: 0, checks: 0, failures: 0, lastSeq: null };
@@ -220,7 +220,7 @@
@@ -220,7 +220,7 @@
later('retry', () => { if (document.hidden) rt.state = 'hidden'; else open(); }, Math.min(9e5, 3e4 * 2 ** (rt.failures - 1)) * (0.5 + Math.random() / 2));
later('retry', () => { if (document.hidden) rt.state = 'hidden'; else open(); }, Math.min(9e5, 3e4 * 2 ** (rt.failures - 1)) * (0.5 + Math.random() / 2));
}
}
function hide() { if (document.hidden && es) { close(); rt.state = 'hidden'; } }
function hide() { if (document.hidden && es) { close(); rt.state = 'hidden'; } }
- // A Content-Security-Policy whose connect-src leaves Events out: one refusal, then polling only.
+ // A CSP whose connect-src leaves Events out: one refusal, then polling only.
document.addEventListener('securitypolicyviolation', (e) => {
document.addEventListener('securitypolicyviolation', (e) => {
try { if (!rt.url || String(e.blockedURI || '').indexOf(new URL(rt.url, root.location.href).origin) !== 0) return; } catch { return; }
try { if (!rt.url || String(e.blockedURI || '').indexOf(new URL(rt.url, root.location.href).origin) !== 0) return; } catch { return; }
close(); root.clearTimeout(t.retry); t.retry = null; rt.state = 'blocked';
close(); root.clearTimeout(t.retry); t.retry = null; rt.state = 'blocked';
@@ -253,7 +253,8 @@
@@ -253,7 +253,8 @@
let stopped = false;
let stopped = false;
root.addEventListener('focus', () => { if (!stopped) check(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('online', () => { if (stopped) return; if (rt.state === 'failed' || rt.state === 'backoff') { root.clearTimeout(t.retry); rt.state = 'off'; rt.failures = 0; live(); } check(true); });
+ root.addEventListener('openvibe-auth-changed', () => { acct++; if (!stopped && pending && !busyWith) { waiting = []; pending = null; consider(latest); } });
root.addEventListener('pagehide', () => flush(true, true));
root.addEventListener('pagehide', () => flush(true, true));
document.addEventListener('visibilitychange', () => {
document.addEventListener('visibilitychange', () => {
if (stopped) return;
if (stopped) return;
@@ -213,7 +213,7 @@ const OUTCOMES = {
@@ -213,7 +213,7 @@ const OUTCOMES = {
prompted: /^(optional|required|window|contract)$/,
prompted: /^(optional|required|window|contract)$/,
applied: /^(content|server|style)(\+(content|server|style)){0,2}$/,
applied: /^(content|server|style)(\+(content|server|style)){0,2}$/,
reloaded: /^(user|required|window|contract)$/,
reloaded: /^(user|required|window|contract)$/,
- deferred: /^(typing|dirty|protected|media|capture|active)$/,
+ deferred: /^(typing|dirty|protected|media|capture|active|account)$/,
failed: /^(style|style-timeout|content|origin|script)$/,
failed: /^(style|style-timeout|content|origin|script)$/,
};
};
const MAX_BODY = 4096;
const MAX_BODY = 4096;
new file mode 100644
new file mode 100644
@@ -0,0 +1,168 @@
@@ -0,0 +1,168 @@
+'use strict';
+// An account switch during an update (D46 scenario 9), release-watch.js + release-update.js in a linkedom page.
+// Sites like Live switch accounts in place: the page stays, the session cookie changes and the app announces
+// it with the `openvibe-auth-changed` window event. An in-place update fetches its regions with the session
+// (credentials: same-origin), so what a region holds can depend on who is signed in. Checked:
+// - /release.json is always read without credentials: whether a tab updates never depends on the account;
+// - a region fetched for the previous account and still waiting (focus inside it) is fetched again for the
+// new one, never committed as it was;
+// - a region whose fetch was in flight when the account changed is fetched again, not committed;
+// - the switch itself reloads nothing, prompts nothing, opens no second release stream, and a switch with no
+// update pending fetches nothing;
+// - a required reload deferred by typing is not hurried by a switch.
+// Lines `[metric] name=value` are read by OpenVibe.Host's release-acceptance runner.
+const assert = require('assert');
+const { openPage, manifestFor } = require('../release-compat');
+
+const URL_ = 'https://site.test/docs/a';
+const A = 'aaaaaaa'; const B = 'bbbbbbb';
+const comps = (o) => ({ styles: { kind: 'style', version: 'css1' }, docs: { kind: 'content', version: 'd1' }, server: { kind: 'server', version: 'r1' }, ...o });
+const assets = { '/css/app.css': { url: '/css/app.css?v=111111111111', component: 'styles' } };
+const M1 = manifestFor({ service: 'test', release: A, components: comps(), assets });
+const M2 = manifestFor({ service: 'test', release: B, components: comps({ docs: { kind: 'content', version: 'd2' }, server: { kind: 'server', version: 'r2' } }), assets });
+
+const PAGE = (extra = '') => `<!doctype html><html><head>
+<meta name="ov-release" content="${A}" data-url="/release.json">
+<link rel="stylesheet" href="/css/app.css?v=111111111111">
+</head><body><main data-ov-content="docs" data-ov-rev="1"><p>old text for alice</p>${extra}</main></body></html>`;
+// The server renders the region for whoever the request's cookie names (none: a guest).
+const REGION = (who) => `<!doctype html><html><body><main data-ov-content="docs" data-ov-rev="2"><p>new text for ${who || 'a guest'}</p></main></body></html>`;
+
+function fakeEventSource() {
+ const all = [];
+ class FakeEventSource {
+ constructor(url) { this.url = url; this.closed = false; all.push(this); }
+ addEventListener() {}
+ close() { this.closed = true; }
+ }
+ return { FakeEventSource, all };
+}
+
+async function open({ html = PAGE(), served = M1, hidden = true, hold = false } = {}) {
+ const es = fakeEventSource();
+ const state = { served, account: 'alice', held: [], requests: [] };
+ const p = await openPage({
+ url: URL_, html, hidden, globals: { EventSource: es.FakeEventSource },
+ config: { eventsUrl: 'https://events.test/realtime/stream', service: 'test' },
+ serve: (u, init) => {
+ const { pathname } = new URL(u);
+ // The cookie goes along unless the request omits credentials.
+ const who = init && init.credentials === 'omit' ? null : state.account;
+ state.requests.push({ pathname, credentials: init && init.credentials, who });
+ if (pathname === '/release.json') return { json: state.served };
+ if (pathname === '/docs/a') {
+ if (hold) return new Promise((resolve) => state.held.push(() => resolve({ body: REGION(who) })));
+ return { body: REGION(who) };
+ }
+ return { status: 404 };
+ },
+ });
+ await p.settle();
+ p.state = state; p.es = es;
+ p.switchTo = async (who) => { state.account = who; p.dispatch('openvibe-auth-changed'); await p.settle(); };
+ p.regionFetches = () => state.requests.filter((r) => r.pathname === '/docs/a');
+ p.text = () => p.document.querySelector('main').textContent.trim();
+ return p;
+}
+
+const metrics = { 'account-switch.stale-regions-committed': 0, 'account-switch.reloads': 0, 'account-switch.prompts': 0, 'account-switch.release-streams': 0, 'account-switch.manifest-reads-with-credentials': 0 };
+const printMetrics = () => { for (const [k, v] of Object.entries(metrics)) console.log(`[metric] ${k}=${v}`); };
+const note = (p) => {
+ if (/alice/.test(p.text()) && p.release.current === B) metrics['account-switch.stale-regions-committed']++;
+ metrics['account-switch.reloads'] += p.reloads;
+ metrics['account-switch.prompts'] += p.toasts.length;
+ metrics['account-switch.release-streams'] = Math.max(metrics['account-switch.release-streams'], p.es.all.filter((s) => !s.closed).length);
+ metrics['account-switch.manifest-reads-with-credentials'] += p.state.requests.filter((r) => r.pathname === '/release.json' && r.credentials !== 'omit').length;
+};
+
+(async () => {
+ // ── A region waiting (focus inside it) when the account changes: fetched again for the new account ──
+ {
+ const p = await open({ html: PAGE('<input>') });
+ p.focus(p.document.querySelector('main input'));
+ p.state.served = M2;
+ await p.release.check(); await p.settle();
+ assert.strictEqual(p.release.state().waiting, 1, 'the focused region waits');
+ assert.deepStrictEqual(p.regionFetches().map((r) => r.who), ['alice'], 'fetched once, with alice\'s session');
+ await p.switchTo('bob');
+ assert.deepStrictEqual([p.reloads, p.toasts.length], [0, 0], 'the switch reloads nothing and prompts nothing');
+ assert.strictEqual(p.es.all.length, 1, 'still one release stream');
+ p.blur(); p.tick(); await p.settle();
+ note(p);
+ assert.match(p.text(), /new text for bob/, 'the region committed is the one rendered for the account now signed in');
+ assert.ok(!/alice/.test(p.text()), 'nothing rendered for alice reaches the page after the switch');
+ assert.deepStrictEqual(p.regionFetches().map((r) => r.who), ['alice', 'bob'], 'fetched again for bob, once');
+ assert.strictEqual(p.release.current, B, 'and the release is adopted');
+ assert.strictEqual(p.release.state().waiting, 0);
+ p.stop();
+ }
+
+ // ── The region fetch is in flight when the account changes: that answer is not committed ──
+ {
+ const p = await open({ hold: true });
+ p.state.served = M2;
+ const done = p.release.check();
+ await p.settle();
+ assert.deepStrictEqual(p.regionFetches().map((r) => r.who), ['alice'], 'the region fetch started with alice\'s session');
+ await p.switchTo('bob');
+ p.state.held.shift()(); // alice's answer arrives after the switch
+ await p.settle();
+ // The answer for bob is fetched and arrives too.
+ while (p.state.held.length) { p.state.held.shift()(); await p.settle(); }
+ await done; await p.settle();
+ note(p);
+ assert.match(p.text(), /new text for bob/, 'the page shows what the server renders for bob');
+ assert.deepStrictEqual(p.regionFetches().map((r) => r.who), ['alice', 'bob']);
+ assert.strictEqual(p.release.current, B);
+ assert.deepStrictEqual([p.reloads, p.toasts.length], [0, 0]);
+ assert.deepStrictEqual(p.metrics(), { deferred: { account: 1 }, applied: { 'content+server': 1 } }, 'counted: the stale answer as deferred (account), then the update');
+ p.stop();
+ }
+
+ // ── Signing out is an account change too: a guest never sees the signed-in rendering ──
+ {
+ const p = await open({ html: PAGE('<input>') });
+ p.focus(p.document.querySelector('main input'));
+ p.state.served = M2;
+ await p.release.check(); await p.settle();
+ await p.switchTo(null);
+ p.blur(); p.tick(); await p.settle();
+ note(p);
+ assert.match(p.text(), /new text for a guest/);
+ p.stop();
+ }
+
+ // ── Nothing pending: a switch fetches nothing and changes nothing ──
+ {
+ const p = await open();
+ const before = p.state.requests.length;
+ await p.switchTo('bob');
+ note(p);
+ assert.strictEqual(p.state.requests.length, before, 'no request');
+ assert.deepStrictEqual([p.reloads, p.toasts.length, p.es.all.length, p.release.current], [0, 0, 1, A]);
+ p.stop();
+ }
+
+ // ── A required reload deferred by typing is not hurried by a switch ──
+ {
+ const p = await open({ html: PAGE('<textarea></textarea>'), hidden: false });
+ p.focus(p.document.querySelector('textarea'));
+ p.state.served = { ...M2, components: comps({ shell: { kind: 'script', version: 's2' } }), min_client_release: B };
+ await p.release.check(); await p.settle();
+ assert.deepStrictEqual([p.reloads, p.toasts.length], [0, 1], 'required: prompted, not reloaded under the person');
+ await p.switchTo('bob');
+ p.tick(); await p.settle();
+ metrics['account-switch.reloads'] += p.reloads;
+ assert.strictEqual(p.reloads, 0, 'not while the text field has focus, switch or not');
+ p.blur(); p.setHidden(true);
+ assert.strictEqual(p.reloads, 1, 'reloaded at the next safe moment');
+ p.stop();
+ }
+
+ // The update decision is the same for every account: /release.json never carries the session.
+ assert.strictEqual(metrics['account-switch.manifest-reads-with-credentials'], 0);
+ assert.strictEqual(metrics['account-switch.stale-regions-committed'], 0);
+ assert.strictEqual(metrics['account-switch.release-streams'], 1);
+ printMetrics();
+ console.log('release account switch: all checks passed');
+})().catch((e) => { printMetrics(); console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,197 @@
@@ -0,0 +1,197 @@
+'use strict';
+// Resuming an offline tab (D46 scenario 14), release-watch.js + release-update.js in a linkedom page with a
+// fake clock and a fake EventSource. A laptop lid closes or the network drops; releases ship meanwhile; the
+// tab comes back. Checked:
+// - offline, the checks fail quietly: no prompt, no reload, no failure counted, the release stream backs off;
+// - back online (the `online` event), the release stream reconnects at once from its cursor, whether it had
+// given up or was still backing off, and one /release.json read brings the tab to the newest release,
+// skipping the ones it missed, in place when the change allows;
+// - a connection lost in the middle of an in-place update leaves the page whole (old styles, old region)
+// and prompts once; coming back does not prompt again;
+// - a tab away past the mixed-version window reloads only when safe (never under a focused field);
+// - a hidden tab keeps its stream closed until it is shown again, then resumes from the cursor.
+// Lines `[metric] name=value` are read by OpenVibe.Host's release-acceptance runner.
+const assert = require('assert');
+const { openPage, manifestFor } = require('../release-compat');
+
+const URL_ = 'https://site.test/docs/a';
+const A = 'aaaaaaa'; const B = 'bbbbbbb'; const C = 'ccccccc';
+const comps = (o) => ({ styles: { kind: 'style', version: 'css1' }, docs: { kind: 'content', version: 'd1' }, shell: { kind: 'script', version: 'sh1' }, server: { kind: 'server', version: 'r1' }, ...o });
+const assets = (v) => ({ '/css/app.css': { url: `/css/app.css?v=${v}`, component: 'styles' } });
+const M = (release, o = {}, css = '111111111111', releasedAt) => manifestFor({ service: 'test', release, components: comps(o), assets: assets(css), releasedAt, metricsUrl: '/release-metrics' });
+const M1 = M(A);
+const MB = M(B, { docs: { kind: 'content', version: 'd2' } });
+const MC = M(C, { docs: { kind: 'content', version: 'd3' }, styles: { kind: 'style', version: 'css3' } }, '333333333333');
+
+const PAGE = (extra = '') => `<!doctype html><html><head>
+<meta name="ov-release" content="${A}" data-url="/release.json">
+<link rel="stylesheet" href="/css/app.css?v=111111111111">
+</head><body><main data-ov-content="docs" data-ov-rev="1"><p>old text</p>${extra}</main></body></html>`;
+const REGION = (rev) => `<!doctype html><html><body><main data-ov-content="docs" data-ov-rev="${rev}"><p>text of ${rev}</p></main></body></html>`;
+
+function fakeEventSource() {
+ const all = [];
+ class FakeEventSource {
+ constructor(url) { this.url = url; this.closed = false; this.listeners = {}; all.push(this); }
+ addEventListener(type, f) { (this.listeners[type] = this.listeners[type] || []).push(f); }
+ close() { this.closed = true; }
+ opened() { if (this.onopen) this.onopen({}); }
+ send(seq, event) { if (this.onmessage) this.onmessage({ data: JSON.stringify({ seq, event }), lastEventId: String(seq) }); }
+ fail() { if (this.onerror) this.onerror({}); }
+ }
+ return { FakeEventSource, all };
+}
+const released = (service, release, n) => ({
+ event_id: `evt_01JAB2C3D4E5F6G7H8J9K0M${String(n).padStart(3, '0')}`, event_type: 'host.release.published', version: 1, source: 'host',
+ visibility: 'public', subject: { type: 'release', id: `${service}:${release}` }, payload: { service, release, commit: null, deployed_at: '2026-09-27T08:00:00.000Z' },
+});
+
+async function open({ html = PAGE(), served = M1, hidden = true } = {}) {
+ const clock = { now: Date.parse('2026-09-27T10:00:00Z') };
+ class FakeDate extends Date {
+ constructor(...a) { super(...(a.length ? a : [clock.now])); }
+ static now() { return clock.now; }
+ }
+ const es = fakeEventSource();
+ const state = { served, offline: false, failNext: false, requests: [] };
+ const p = await openPage({
+ url: URL_, html, hidden,
+ config: { eventsUrl: 'https://events.test/realtime/stream', service: 'test' },
+ globals: { EventSource: es.FakeEventSource, Date: FakeDate, Math: Object.assign(Object.create(Math), { random: () => 0 }) },
+ serve: (u) => {
+ const { pathname } = new URL(u);
+ state.requests.push({ pathname, at: clock.now, offline: state.offline });
+ if (state.offline) throw new TypeError('Failed to fetch');
+ if (pathname === '/release.json') return { json: state.served };
+ if (pathname === '/docs/a') {
+ if (state.failNext) { state.failNext = false; throw new TypeError('Failed to fetch'); }
+ return { body: REGION(state.served.components.docs.version) };
+ }
+ return { status: 404 };
+ },
+ });
+ await p.settle();
+ p.state = state; p.es = es; p.clock = clock;
+ p.later = (ms) => { clock.now += ms; };
+ p.manifestReads = () => state.requests.filter((r) => r.pathname === '/release.json' && !r.offline).length;
+ p.rt = () => p.release.state().realtime;
+ p.text = () => p.document.querySelector('main').textContent.trim();
+ p.css = () => Array.from(p.document.querySelectorAll('link[rel~="stylesheet"]')).map((l) => l.getAttribute('href'));
+ return p;
+}
+
+const printMetrics = () => { for (const [k, v] of Object.entries(metrics)) console.log(`[metric] ${k}=${v}`); };
+const metrics = { 'offline-resume.reloads-while-offline': 0, 'offline-resume.prompts-while-offline': 0, 'offline-resume.failures-counted-offline': 0, 'offline-resume.stream-reconnect-delay-ms': 0, 'offline-resume.manifest-reads-on-resume': 0, 'offline-resume.partial-pages': 0, 'offline-resume.reloads-while-typing': 0 };
+
+(async () => {
+ // ── Offline while two releases ship, the stream still backing off; then back online ──
+ for (const failures of [3, 6]) {
+ const p = await open({ hidden: false });
+ const s0 = p.es.all[0];
+ s0.opened();
+ s0.send(7, released('other', B, 1));
+ assert.strictEqual(p.rt().lastSeq, 7);
+ // The network drops.
+ p.state.offline = true;
+ s0.fail();
+ let retrySetAt = p.clock.now;
+ for (let i = 1; i < failures; i++) {
+ assert.strictEqual(p.rt().state, 'backoff');
+ const wait = Math.min(...p.timeouts());
+ p.later(wait); p.fireTimeouts((ms) => ms === wait); await p.settle();
+ p.es.all[p.es.all.length - 1].fail();
+ retrySetAt = p.clock.now;
+ }
+ assert.strictEqual(p.rt().state, failures >= 6 ? 'failed' : 'backoff', `${failures} failures`);
+ // Releases ship while it is away (30 s more, less than any pending backoff); its checks fail quietly.
+ p.state.served = MB;
+ p.later(30 * 1000); p.tick(); await p.settle();
+ p.dispatch('focus'); await p.release.check(); await p.settle();
+ p.state.served = MC;
+ assert.ok(p.state.requests.some((r) => r.offline && r.pathname === '/release.json'), 'it did try');
+ metrics['offline-resume.reloads-while-offline'] += p.reloads;
+ metrics['offline-resume.prompts-while-offline'] += p.toasts.length;
+ metrics['offline-resume.failures-counted-offline'] += Object.values(p.metrics().failed || {}).reduce((a, b) => a + b, 0);
+ assert.deepStrictEqual([p.reloads, p.toasts.length, p.metrics(), p.release.current], [0, 0, {}, A], 'offline: no prompt, no reload, nothing counted as failed');
+
+ // Back online.
+ p.state.offline = false;
+ const streams = p.es.all.length; const reads = p.manifestReads();
+ const retry = p.timeouts().length ? Math.min(...p.timeouts()) - (p.clock.now - retrySetAt) : 0; // what is left of the backoff
+ p.dispatch('online'); await p.settle();
+ const reconnected = p.es.all.length > streams;
+ // Not reconnected at once: the stream waits out its backoff, and that wait is the delay.
+ if (!reconnected) metrics['offline-resume.stream-reconnect-delay-ms'] = Math.max(metrics['offline-resume.stream-reconnect-delay-ms'], retry);
+ assert.ok(reconnected, `back online after ${failures} failures: the release stream reconnects at once`);
+ assert.match(p.es.all[p.es.all.length - 1].url, /last_event_id=7$/, 'from its cursor');
+ assert.strictEqual(p.rt().failures, 0);
+ p.settleStyles(true); await p.settle();
+ metrics['offline-resume.manifest-reads-on-resume'] = Math.max(metrics['offline-resume.manifest-reads-on-resume'], p.manifestReads() - reads);
+ assert.strictEqual(p.manifestReads() - reads, 1, 'one /release.json read');
+ assert.strictEqual(p.release.current, C, 'straight to the newest release, the missed one skipped');
+ assert.match(p.text(), /text of d3/);
+ assert.deepStrictEqual(p.css(), ['https://site.test/css/app.css?v=333333333333']);
+ assert.deepStrictEqual([p.reloads, p.toasts.length, p.metrics()], [0, 0, { applied: { 'content+style': 1 } }], 'in place, nothing asked of the person');
+ assert.deepStrictEqual(p.timeouts().filter((ms) => ms >= 15000), [], 'no retry left pending');
+ // Focus and visibility right after: nothing more to read.
+ p.dispatch('focus'); p.setHidden(false); await p.settle();
+ assert.strictEqual(p.manifestReads() - reads, 1);
+ p.stop();
+ }
+
+ // ── The connection drops in the middle of an in-place update ──
+ {
+ const p = await open();
+ p.state.served = MC;
+ p.state.failNext = true;
+ await p.release.check(); await p.settle();
+ if (p.text() !== 'old text' || p.css().join() !== '/css/app.css?v=111111111111') metrics['offline-resume.partial-pages']++;
+ assert.deepStrictEqual([p.text(), p.css()], ['old text', ['/css/app.css?v=111111111111']], 'the page is whole: old region, old styles');
+ assert.deepStrictEqual([p.release.current, p.toasts.length, p.reloads, p.metrics()], [A, 1, 0, { failed: { content: 1 }, prompted: { optional: 1 } }], 'failed in place: prompted once');
+ p.later(5 * 60 * 1000);
+ p.dispatch('online'); await p.settle();
+ p.tick(); await p.settle();
+ assert.deepStrictEqual([p.toasts.length, p.reloads], [1, 0], 'coming back does not prompt again');
+ p.stop();
+ }
+
+ // ── Away past the mixed-version window, a text field focused ──
+ {
+ const p = await open({ html: PAGE('<textarea></textarea>'), hidden: false });
+ p.focus(p.document.querySelector('textarea'));
+ p.state.offline = true;
+ p.later(30 * 3600 * 1000);
+ p.state.served = M(B, { shell: { kind: 'script', version: 'sh2' } }, '111111111111', new Date(p.clock.now - 29 * 3600 * 1000).toISOString());
+ p.tick(); await p.settle();
+ p.state.offline = false;
+ p.dispatch('online'); await p.settle();
+ metrics['offline-resume.reloads-while-typing'] += p.reloads;
+ assert.deepStrictEqual([p.reloads, p.toasts.length], [0, 1], 'outside the window: prompted, not reloaded under the person');
+ p.tick(); await p.settle();
+ metrics['offline-resume.reloads-while-typing'] += p.reloads;
+ assert.deepStrictEqual(p.metrics(), { prompted: { window: 1 }, deferred: { typing: 1 } });
+ p.blur(); p.setHidden(true);
+ assert.strictEqual(p.reloads, 1, 'and reloads at the next safe moment');
+ p.stop();
+ }
+
+ // ── Hidden: the stream stays closed until the tab is shown, then resumes from the cursor ──
+ {
+ const p = await open({ hidden: true });
+ const s0 = p.es.all[0];
+ s0.opened(); s0.send(11, released('other', B, 2));
+ p.fireTimeouts((ms) => ms === 300000);
+ assert.deepStrictEqual([s0.closed, p.rt().state], [true, 'hidden'], 'hidden 5 minutes: closed');
+ p.state.offline = true; p.later(3600e3); p.tick(); await p.settle();
+ p.state.offline = false;
+ p.dispatch('online'); await p.settle();
+ assert.strictEqual(p.es.all.length, 1, 'online while hidden: no stream yet');
+ p.setHidden(false); await p.settle();
+ assert.strictEqual(p.es.all.length, 2, 'shown: one stream');
+ assert.match(p.es.all[1].url, /last_event_id=11$/);
+ p.stop();
+ }
+
+ printMetrics();
+ console.log('release offline resume: all checks passed');
+})().catch((e) => { printMetrics(); console.error(e); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,88 @@
@@ -0,0 +1,88 @@
+'use strict';
+// A feed update while reading (D46 scenario 4), release-watch.js + release-update.js in a linkedom page.
+// A content release changes a feed region while the person reads the article under it. Checked:
+// - a feed region above the viewport that grows (or shrinks) is replaced and the page scrolls by exactly the
+// change, so what the reader is looking at does not move;
+// - a region the reader is inside keeps its own scroll position;
+// - a region in view is replaced without scrolling the page;
+// - none of it reloads or prompts.
+// linkedom has no layout, so each region's box is scripted: its height depends on whether it holds the new text.
+// Lines `[metric] name=value` are read by OpenVibe.Host's release-acceptance runner.
+const assert = require('assert');
+const { openPage, manifestFor } = require('../release-compat');
+
+const URL_ = 'https://site.test/feed';
+const A = 'aaaaaaa'; const B = 'bbbbbbb';
+const comps = (o) => ({ feed: { kind: 'content', version: 'f1' }, side: { kind: 'content', version: 's1' }, shell: { kind: 'script', version: 'sh1' }, ...o });
+const M1 = manifestFor({ service: 'test', release: A, components: comps() });
+const M2 = manifestFor({ service: 'test', release: B, components: comps({ feed: { kind: 'content', version: 'f2' }, side: { kind: 'content', version: 's2' } }) });
+const PAGE = `<!doctype html><html><head><meta name="ov-release" content="${A}" data-url="/release.json"></head><body>
+<section data-ov-content="feed" data-ov-rev="1"><p>old item</p></section>
+<article><p>The article the person is reading.</p></article>
+<aside data-ov-content="side" data-ov-rev="1"><p>old side</p></aside>
+</body></html>`;
+const FRESH = `<!doctype html><html><body>
+<section data-ov-content="feed" data-ov-rev="2"><p>new item</p><p>new item 2</p></section>
+<aside data-ov-content="side" data-ov-rev="2"><p>new side</p></aside>
+</body></html>`;
+
+async function open({ feedTop, feedOld, feedNew, sideTop = 200 }) {
+ const scrolled = []; const state = { served: M1 };
+ const p = await openPage({
+ url: URL_, html: PAGE, hidden: false,
+ globals: { scrollBy: (x, y) => scrolled.push(y) },
+ serve: (u) => {
+ const { pathname } = new URL(u);
+ if (pathname === '/release.json') return { json: state.served };
+ if (pathname === '/feed') return { body: FRESH };
+ return { status: 404 };
+ },
+ });
+ const box = (el, top, oldH, newH) => {
+ el.getBoundingClientRect = () => { const h = /new/.test(el.textContent) ? newH : oldH; return { top, bottom: top + h, height: h, left: 0, right: 800, width: 800 }; };
+ };
+ box(p.document.querySelector('section'), feedTop, feedOld, feedNew);
+ box(p.document.querySelector('aside'), sideTop, 100, 180);
+ await p.settle();
+ p.state = state; p.scrolled = scrolled;
+ return p;
+}
+
+const metrics = { 'feed-reading.position-shift-px': 0, 'feed-reading.in-view-scrolls': 0, 'feed-reading.region-scroll-lost-px': 0, 'feed-reading.reloads': 0, 'feed-reading.prompts': 0 };
+const printMetrics = () => { for (const [k, v] of Object.entries(metrics)) console.log(`[metric] ${k}=${v}`); };
+
+(async () => {
+ // A feed above the viewport grows by 300 px, or shrinks by 200 px: the page scrolls by that much.
+ for (const [oldH, newH] of [[600, 900], [600, 400]]) {
+ const p = await open({ feedTop: -1000, feedOld: oldH, feedNew: newH });
+ const feed = p.document.querySelector('section');
+ feed.scrollTop = 25; // the feed box scrolled on its own too
+ p.state.served = M2;
+ await p.release.check(); await p.settle();
+ const moved = (newH - oldH) - p.scrolled.reduce((a, b) => a + b, 0);
+ metrics['feed-reading.position-shift-px'] = Math.max(metrics['feed-reading.position-shift-px'], Math.abs(moved));
+ metrics['feed-reading.region-scroll-lost-px'] = Math.max(metrics['feed-reading.region-scroll-lost-px'], Math.abs(25 - feed.scrollTop));
+ metrics['feed-reading.reloads'] += p.reloads; metrics['feed-reading.prompts'] += p.toasts.length;
+ assert.match(feed.textContent, /new item 2/, 'the feed is updated');
+ assert.strictEqual(p.release.current, B);
+ assert.deepStrictEqual(p.scrolled, [newH - oldH], `the page scrolls by the feed's change (${newH - oldH} px), once`);
+ assert.strictEqual(moved, 0, 'what the reader looks at does not move');
+ assert.strictEqual(feed.scrollTop, 25, 'the feed keeps its own scroll');
+ assert.deepStrictEqual([p.reloads, p.toasts.length], [0, 0]);
+ p.stop();
+ }
+ // Everything in view (the feed at the top of the viewport, the aside below it): replaced, no scrolling.
+ {
+ const p = await open({ feedTop: 0, feedOld: 600, feedNew: 900 });
+ p.state.served = M2;
+ await p.release.check(); await p.settle();
+ metrics['feed-reading.in-view-scrolls'] += p.scrolled.length;
+ metrics['feed-reading.reloads'] += p.reloads; metrics['feed-reading.prompts'] += p.toasts.length;
+ assert.match(p.document.querySelector('aside').textContent, /new side/);
+ assert.deepStrictEqual(p.scrolled, [], 'a region in view is not compensated by the page');
+ p.stop();
+ }
+ assert.strictEqual(metrics['feed-reading.position-shift-px'], 0);
+ printMetrics();
+ console.log('release reading: all checks passed');
+})().catch((e) => { printMetrics(); console.error(e); process.exit(1); });
@@ -69,6 +69,7 @@ const counts = (p) => p.beacons.reduce((acc, b) => { for (const [o, rs] of Objec
@@ -69,6 +69,7 @@ const counts = (p) => p.beacons.reduce((acc, b) => { for (const [o, rs] of Objec
// code, +0.4 KB of comments); its budget moved from 3.5 to 5 KB for that, and only for that.
// 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.
// 1.18.0: session beats and the prompted count (+0.25 KB) fit by shortening comments: 4.96 KB.
// 1.20.0: client generations (+0.05 KB) fit by a tighter header comment: 4.94 KB.
// 1.20.0: client generations (+0.05 KB) fit by a tighter header comment: 4.94 KB.
+// 1.23.1: the account-switch refetch and the online reconnect (+0.12 KB) fit by shorter comments: 4.99 KB.
{
{
const fs = require('fs'); const path = require('path'); const { brotli } = require('../scripts/size-report');
const fs = require('fs'); const path = require('path'); const { brotli } = require('../scripts/size-report');
const size = (f) => brotli(fs.readFileSync(path.join(__dirname, '..', f)));
const size = (f) => brotli(fs.readFileSync(path.join(__dirname, '..', f)));
@@ -166,6 +166,8 @@ const valid11 = (m) => { const ok = validate11(m); return ok || JSON.stringify(v
@@ -166,6 +166,8 @@ const valid11 = (m) => { const ok = validate11(m); return ok || JSON.stringify(v
assert.strictEqual(counter('applied', 'content+style'), 1);
assert.strictEqual(counter('applied', 'content+style'), 1);
assert.strictEqual(counter('deferred', 'typing'), 2);
assert.strictEqual(counter('deferred', 'typing'), 2);
assert.strictEqual(counter('deferred', 'other'), 1, 'an unknown reason counts as other');
assert.strictEqual(counter('deferred', 'other'), 1, 'an unknown reason counts as other');
+ assert.strictEqual(await post({ counts: { deferred: { account: 1 } } }, {}, '10.0.0.5'), 204);
+ assert.strictEqual(counter('deferred', 'account'), 1, 'a region fetched again after an account switch (1.23.1)');
assert.strictEqual(counter('failed', 'style'), 50, 'one report counts at most 50 per reason');
assert.strictEqual(counter('failed', 'style'), 50, 'one report counts at most 50 per reason');
assert.strictEqual(await post('not json', {}, '10.0.0.2'), 400);
assert.strictEqual(await post('not json', {}, '10.0.0.2'), 400);
assert.strictEqual(await post({ counts: { reloaded: { user: 1 } } }, { 'sec-gpc': '1' }, '10.0.0.3'), 204);
assert.strictEqual(await post({ counts: { reloaded: { user: 1 } } }, { 'sec-gpc': '1' }, '10.0.0.3'), 204);