openvibers/openvibe.host Commits
Shared-host outage test (WS-P task 5): scripts/shared-host-outage.js loads every public site with openvibe.network blocked and checks content, navigation and theme; the recorded production run passes 18/18 after the Games navigation fix
Changes
nnew file mode 100644@@@ -0,0 +1,48 @@++# Shared-host outage test (roadmap WS-P task 5)++++Every OpenVibe site must still render its content, its navigation and its theme when openvibe.network++(and every *.openvibe.network host) does not answer: the OpenVibe Frame (navbar, footer, "shipped"++widget) and the theme loader load from there, so a site has to degrade, never go blank.++++`scripts/shared-host-outage.js` loads each public site's home page in headless Chrome with the Network++domain blocked, and checks:++- **content:** at least 200 characters of text;++- **navigation:** at least 3 visible links in a nav or header;++- **theme:** a styled page;++- **dependency:** that requests to openvibe.network really were blocked.++++The site list comes from the registry. Run it after a Frame or theme change:++++```sh++node scripts/shared-host-outage.js --out docs/shared-host-outage.md++```++++## Findings++++**2026-09-26, first run:** 16 of 18 sites passed.++- `openvibe.games` had no navigation without openvibe.network: its navbar mounted only from there and it had no server-rendered footer.++ - Games `ef3b2cf` added a plain navigation that shows until the shared navbar mounts, and deferred its theme loader.++- `openvibe.media` was reported without a theme, but a screenshot showed it fully themed: it inlines its own colours, not the token names. The check now also accepts a page's own text colour on a styled background.++++### Run 2026-09-26T01:25:24.760Z++++| Site | Result | Text (chars) | Nav links | Theme | Blocked requests |++|---|---|---|---|---|---|++| https://openre.stream | pass | 2710 | 7 | yes | 5 |++| https://openvibe.blog | pass | 2366 | 7 | yes | 6 |++| https://openvibe.chat | pass | 2831 | 8 | yes | 4 |++| https://openvibe.codes | pass | 1801 | 10 | yes | 6 |++| https://openvibe.community | pass | 3058 | 9 | yes | 6 |++| https://openvibe.coupons | pass | 2409 | 7 | yes | 5 |++| https://openvibe.deals | pass | 2392 | 7 | yes | 5 |++| https://openvibe.games | pass | 1372 | 7 | yes | 3 |++| https://openvibe.host | pass | 3163 | 7 | yes | 5 |++| https://openvibe.live | pass | 12031 | 12 | yes | 6 |++| https://openvibe.media | pass | 7813 | 9 | yes | 5 |++| https://openvibe.news | pass | 2517 | 7 | yes | 5 |++| https://openvibe.reviews | pass | 2481 | 7 | yes | 5 |++| https://openvibe.tips | pass | 2527 | 7 | yes | 5 |++| https://openvibe.tools | pass | 13843 | 8 | yes | 5 |++| https://openvibe.trade | pass | 2419 | 7 | yes | 5 |++| https://openvibe.vip | pass | 2478 | 7 | yes | 5 |++| https://openvibe.wiki | pass | 1298 | 7 | yes | 6 |nnew file mode 100644@@@ -0,0 +1,129 @@++#!/usr/bin/env node++'use strict';++/**++ * Shared-host outage test (roadmap WS-P task 5): every OpenVibe site still renders its content, its++ * navigation and its theme when openvibe.network (and every *.openvibe.network host) does not answer. The++ * Frame, the theme loader and the "shipped" widget load from there; a site must degrade, never go blank.++ *++ * node scripts/shared-host-outage.js [--sites https://openvibe.live,https://openvibe.tools] [--out report.md] [--json]++ *++ * Without --sites the public sites come from OpenVibe.Network's registry (asked once, before the block):++ * every service with a public origin outside openvibe.network. Each home page is loaded in headless Chrome++ * with the Network domain blocked (Network.setBlockedURLs) and checked after it settles:++ * content at least 200 characters of visible text++ * nav a navigation (nav, [role=navigation] or a header) with at least 3 visible links++ * theme a styled page, not the browser default: a background on html or body, and either the++ * theme tokens resolve (--bg-primary or --accent on :root) or the text colour is the page's own++ * blocked at least one request to openvibe.network was blocked (else the page never depended on it,++ * which is reported, not failed)++ * Exit 0 when every site passes, 1 otherwise. Needs Chrome (CHROME_BIN or the usual paths) and Node 22.++ */++const { spawn } = require('child_process');++const fs = require('fs');++const os = require('os');++const path = require('path');++++const args = process.argv.slice(2);++const arg = (n) => { const i = args.indexOf(`--${n}`); return i >= 0 ? args[i + 1] : null; };++const NETWORK = 'https://openvibe.network';++const BLOCK = ['*://openvibe.network/*', '*://*.openvibe.network/*'];++const sleep = (ms) => new Promise((r) => setTimeout(r, ms));++++async function sitesFromRegistry() {++ const r = await fetch(`${NETWORK}/api/v1/registry/services`, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(10000) });++ const body = await r.json();++ const list = Array.isArray(body) ? body : body.services || [];++ const origins = new Set();++ for (const s of list) {++ const o = s.publicOrigin || s.public_origin || s.origin;++ if (!o || !/^https:\/\//.test(o)) continue;++ const host = new URL(o).hostname;++ if (host === 'openvibe.network' || host.endsWith('.openvibe.network')) continue;++ origins.add(new URL(o).origin);++ }++ return [...origins].sort();++}++++async function launchChrome() {++ const bin = [process.env.CHROME_BIN, '/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser'].find((c) => c && fs.existsSync(c));++ if (!bin) throw new Error('Chrome not found (set CHROME_BIN)');++ const port = 9800 + Math.floor(Math.random() * 5000);++ const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-outage-'));++ const proc = spawn(bin, ['--headless=new', `--remote-debugging-port=${port}`, '--no-sandbox', '--no-first-run', `--user-data-dir=${profile}`, '--window-size=1280,900', 'about:blank'], { stdio: 'ignore' });++ let targets = null;++ for (let i = 0; i < 120 && !targets; i++) {++ try { targets = await (await fetch(`http://127.0.0.1:${port}/json`)).json(); } catch { await sleep(250); }++ }++ if (!targets) { proc.kill('SIGKILL'); throw new Error('Chrome did not start'); }++ const page = targets.find((t) => t.type === 'page');++ const ws = new WebSocket(page.webSocketDebuggerUrl);++ await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject; });++ let id = 0;++ const pending = new Map(), listeners = new Map();++ ws.onmessage = (e) => {++ const msg = JSON.parse(e.data);++ if (msg.id && pending.has(msg.id)) { pending.get(msg.id)(msg); pending.delete(msg.id); return; }++ for (const fn of listeners.get(msg.method) || []) fn(msg.params);++ };++ const send = (method, params = {}) => new Promise((resolve) => { const n = ++id; pending.set(n, resolve); ws.send(JSON.stringify({ id: n, method, params })); });++ const on = (method, fn) => { if (!listeners.has(method)) listeners.set(method, []); listeners.get(method).push(fn); };++ const evaluate = async (expression) => {++ const r = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });++ if (r.result && r.result.exceptionDetails) throw new Error(r.result.exceptionDetails.text);++ return r.result && r.result.result ? r.result.result.value : undefined;++ };++ const close = () => { try { ws.close(); } catch { /* closed */ } proc.kill('SIGKILL'); setTimeout(() => fs.rmSync(profile, { recursive: true, force: true }), 500).unref(); };++ return { send, on, evaluate, close };++}++++const CHECK = `(() => {++ const visible = (el) => { const r = el.getBoundingClientRect(); const s = getComputedStyle(el); return r.width > 0 && r.height > 0 && s.visibility !== 'hidden' && s.display !== 'none'; };++ const text = (document.body && document.body.innerText || '').replace(/\\s+/g, ' ').trim();++ const navs = [...document.querySelectorAll('nav, [role=navigation], header')];++ const links = Math.max(0, ...navs.map((n) => [...n.querySelectorAll('a[href]')].filter(visible).length));++ const root = getComputedStyle(document.documentElement);++ const token = (root.getPropertyValue('--bg-primary') || root.getPropertyValue('--accent') || '').trim();++ const bodyBg = getComputedStyle(document.body).backgroundColor;++ const htmlBg = root.backgroundColor;++ const styledBg = [bodyBg, htmlBg].some((c) => c && c !== 'rgba(0, 0, 0, 0)' && c !== 'transparent');++ const ownText = getComputedStyle(document.body).color !== 'rgb(0, 0, 0)';++ return { chars: text.length, navLinks: links, token, styledBg, ownText, title: document.title.slice(0, 80) };++})()`;++++async function checkSite(cdp, origin) {++ const blocked = [];++ await cdp.send('Network.enable');++ await cdp.send('Network.setBlockedURLs', { urls: BLOCK });++ await cdp.send('Network.setCacheDisabled', { cacheDisabled: true });++ const onFail = (p) => { if (p.blockedReason) blocked.push(p.requestId); };++ cdp.on('Network.loadingFailed', onFail);++ await cdp.send('Page.enable');++ await cdp.send('Page.navigate', { url: `${origin}/` });++ await sleep(6000);++ const r = await cdp.evaluate(CHECK);++ const out = {++ site: origin, title: r.title, chars: r.chars, navLinks: r.navLinks, token: r.token || null, styled: r.styledBg, blocked: new Set(blocked).size,++ content: r.chars >= 200, nav: r.navLinks >= 3, theme: r.styledBg && (Boolean(r.token) || r.ownText),++ };++ out.ok = out.content && out.nav && out.theme;++ return out;++}++++(async () => {++ const sites = arg('sites') ? arg('sites').split(',').map((s) => new URL(s.trim()).origin) : await sitesFromRegistry();++ const results = [];++ for (const site of sites) {++ const cdp = await launchChrome();++ try { results.push(await checkSite(cdp, site)); }++ catch (err) { results.push({ site, ok: false, error: err.message }); }++ finally { cdp.close(); }++ }++ if (args.includes('--json')) console.log(JSON.stringify(results, null, 2));++ else for (const r of results) console.log(`${r.ok ? 'ok ' : 'FAIL'} ${r.site} ${r.error || `content ${r.chars} chars, nav ${r.navLinks} links, theme ${r.theme ? 'yes' : 'no'}, ${r.blocked} request(s) to openvibe.network blocked`}`);++ if (arg('out')) {++ const at = new Date().toISOString();++ const rows = results.map((r) => `| ${r.site} | ${r.ok ? 'pass' : 'FAIL'} | ${r.chars ?? '-'} | ${r.navLinks ?? '-'} | ${r.theme ? 'yes' : 'no'} | ${r.blocked ?? '-'} |${r.error ? ` ${r.error}` : ''}`);++ fs.appendFileSync(arg('out'), `\n### Run ${at}\n\n| Site | Result | Text (chars) | Nav links | Theme | Blocked requests |\n|---|---|---|---|---|---|\n${rows.join('\n')}\n`);++ }++ process.exit(results.every((r) => r.ok) ? 0 : 1);++})().catch((err) => { console.error(err); process.exit(2); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,48 @@
@@ -0,0 +1,48 @@
+# Shared-host outage test (roadmap WS-P task 5)
+
+Every OpenVibe site must still render its content, its navigation and its theme when openvibe.network
+(and every *.openvibe.network host) does not answer: the OpenVibe Frame (navbar, footer, "shipped"
+widget) and the theme loader load from there, so a site has to degrade, never go blank.
+
+`scripts/shared-host-outage.js` loads each public site's home page in headless Chrome with the Network
+domain blocked, and checks:
+- **content:** at least 200 characters of text;
+- **navigation:** at least 3 visible links in a nav or header;
+- **theme:** a styled page;
+- **dependency:** that requests to openvibe.network really were blocked.
+
+The site list comes from the registry. Run it after a Frame or theme change:
+
+```sh
+node scripts/shared-host-outage.js --out docs/shared-host-outage.md
+```
+
+## Findings
+
+**2026-09-26, first run:** 16 of 18 sites passed.
+- `openvibe.games` had no navigation without openvibe.network: its navbar mounted only from there and it had no server-rendered footer.
+ - Games `ef3b2cf` added a plain navigation that shows until the shared navbar mounts, and deferred its theme loader.
+- `openvibe.media` was reported without a theme, but a screenshot showed it fully themed: it inlines its own colours, not the token names. The check now also accepts a page's own text colour on a styled background.
+
+### Run 2026-09-26T01:25:24.760Z
+
+| Site | Result | Text (chars) | Nav links | Theme | Blocked requests |
+|---|---|---|---|---|---|
+| https://openre.stream | pass | 2710 | 7 | yes | 5 |
+| https://openvibe.blog | pass | 2366 | 7 | yes | 6 |
+| https://openvibe.chat | pass | 2831 | 8 | yes | 4 |
+| https://openvibe.codes | pass | 1801 | 10 | yes | 6 |
+| https://openvibe.community | pass | 3058 | 9 | yes | 6 |
+| https://openvibe.coupons | pass | 2409 | 7 | yes | 5 |
+| https://openvibe.deals | pass | 2392 | 7 | yes | 5 |
+| https://openvibe.games | pass | 1372 | 7 | yes | 3 |
+| https://openvibe.host | pass | 3163 | 7 | yes | 5 |
+| https://openvibe.live | pass | 12031 | 12 | yes | 6 |
+| https://openvibe.media | pass | 7813 | 9 | yes | 5 |
+| https://openvibe.news | pass | 2517 | 7 | yes | 5 |
+| https://openvibe.reviews | pass | 2481 | 7 | yes | 5 |
+| https://openvibe.tips | pass | 2527 | 7 | yes | 5 |
+| https://openvibe.tools | pass | 13843 | 8 | yes | 5 |
+| https://openvibe.trade | pass | 2419 | 7 | yes | 5 |
+| https://openvibe.vip | pass | 2478 | 7 | yes | 5 |
+| https://openvibe.wiki | pass | 1298 | 7 | yes | 6 |
new file mode 100644
new file mode 100644
@@ -0,0 +1,129 @@
@@ -0,0 +1,129 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * Shared-host outage test (roadmap WS-P task 5): every OpenVibe site still renders its content, its
+ * navigation and its theme when openvibe.network (and every *.openvibe.network host) does not answer. The
+ * Frame, the theme loader and the "shipped" widget load from there; a site must degrade, never go blank.
+ *
+ * node scripts/shared-host-outage.js [--sites https://openvibe.live,https://openvibe.tools] [--out report.md] [--json]
+ *
+ * Without --sites the public sites come from OpenVibe.Network's registry (asked once, before the block):
+ * every service with a public origin outside openvibe.network. Each home page is loaded in headless Chrome
+ * with the Network domain blocked (Network.setBlockedURLs) and checked after it settles:
+ * content at least 200 characters of visible text
+ * nav a navigation (nav, [role=navigation] or a header) with at least 3 visible links
+ * theme a styled page, not the browser default: a background on html or body, and either the
+ * theme tokens resolve (--bg-primary or --accent on :root) or the text colour is the page's own
+ * blocked at least one request to openvibe.network was blocked (else the page never depended on it,
+ * which is reported, not failed)
+ * Exit 0 when every site passes, 1 otherwise. Needs Chrome (CHROME_BIN or the usual paths) and Node 22.
+ */
+const { spawn } = require('child_process');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+
+const args = process.argv.slice(2);
+const arg = (n) => { const i = args.indexOf(`--${n}`); return i >= 0 ? args[i + 1] : null; };
+const NETWORK = 'https://openvibe.network';
+const BLOCK = ['*://openvibe.network/*', '*://*.openvibe.network/*'];
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+async function sitesFromRegistry() {
+ const r = await fetch(`${NETWORK}/api/v1/registry/services`, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(10000) });
+ const body = await r.json();
+ const list = Array.isArray(body) ? body : body.services || [];
+ const origins = new Set();
+ for (const s of list) {
+ const o = s.publicOrigin || s.public_origin || s.origin;
+ if (!o || !/^https:\/\//.test(o)) continue;
+ const host = new URL(o).hostname;
+ if (host === 'openvibe.network' || host.endsWith('.openvibe.network')) continue;
+ origins.add(new URL(o).origin);
+ }
+ return [...origins].sort();
+}
+
+async function launchChrome() {
+ const bin = [process.env.CHROME_BIN, '/usr/bin/google-chrome', '/usr/bin/chromium', '/usr/bin/chromium-browser'].find((c) => c && fs.existsSync(c));
+ if (!bin) throw new Error('Chrome not found (set CHROME_BIN)');
+ const port = 9800 + Math.floor(Math.random() * 5000);
+ const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-outage-'));
+ const proc = spawn(bin, ['--headless=new', `--remote-debugging-port=${port}`, '--no-sandbox', '--no-first-run', `--user-data-dir=${profile}`, '--window-size=1280,900', 'about:blank'], { stdio: 'ignore' });
+ let targets = null;
+ for (let i = 0; i < 120 && !targets; i++) {
+ try { targets = await (await fetch(`http://127.0.0.1:${port}/json`)).json(); } catch { await sleep(250); }
+ }
+ if (!targets) { proc.kill('SIGKILL'); throw new Error('Chrome did not start'); }
+ const page = targets.find((t) => t.type === 'page');
+ const ws = new WebSocket(page.webSocketDebuggerUrl);
+ await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject; });
+ let id = 0;
+ const pending = new Map(), listeners = new Map();
+ ws.onmessage = (e) => {
+ const msg = JSON.parse(e.data);
+ if (msg.id && pending.has(msg.id)) { pending.get(msg.id)(msg); pending.delete(msg.id); return; }
+ for (const fn of listeners.get(msg.method) || []) fn(msg.params);
+ };
+ const send = (method, params = {}) => new Promise((resolve) => { const n = ++id; pending.set(n, resolve); ws.send(JSON.stringify({ id: n, method, params })); });
+ const on = (method, fn) => { if (!listeners.has(method)) listeners.set(method, []); listeners.get(method).push(fn); };
+ const evaluate = async (expression) => {
+ const r = await send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true });
+ if (r.result && r.result.exceptionDetails) throw new Error(r.result.exceptionDetails.text);
+ return r.result && r.result.result ? r.result.result.value : undefined;
+ };
+ const close = () => { try { ws.close(); } catch { /* closed */ } proc.kill('SIGKILL'); setTimeout(() => fs.rmSync(profile, { recursive: true, force: true }), 500).unref(); };
+ return { send, on, evaluate, close };
+}
+
+const CHECK = `(() => {
+ const visible = (el) => { const r = el.getBoundingClientRect(); const s = getComputedStyle(el); return r.width > 0 && r.height > 0 && s.visibility !== 'hidden' && s.display !== 'none'; };
+ const text = (document.body && document.body.innerText || '').replace(/\\s+/g, ' ').trim();
+ const navs = [...document.querySelectorAll('nav, [role=navigation], header')];
+ const links = Math.max(0, ...navs.map((n) => [...n.querySelectorAll('a[href]')].filter(visible).length));
+ const root = getComputedStyle(document.documentElement);
+ const token = (root.getPropertyValue('--bg-primary') || root.getPropertyValue('--accent') || '').trim();
+ const bodyBg = getComputedStyle(document.body).backgroundColor;
+ const htmlBg = root.backgroundColor;
+ const styledBg = [bodyBg, htmlBg].some((c) => c && c !== 'rgba(0, 0, 0, 0)' && c !== 'transparent');
+ const ownText = getComputedStyle(document.body).color !== 'rgb(0, 0, 0)';
+ return { chars: text.length, navLinks: links, token, styledBg, ownText, title: document.title.slice(0, 80) };
+})()`;
+
+async function checkSite(cdp, origin) {
+ const blocked = [];
+ await cdp.send('Network.enable');
+ await cdp.send('Network.setBlockedURLs', { urls: BLOCK });
+ await cdp.send('Network.setCacheDisabled', { cacheDisabled: true });
+ const onFail = (p) => { if (p.blockedReason) blocked.push(p.requestId); };
+ cdp.on('Network.loadingFailed', onFail);
+ await cdp.send('Page.enable');
+ await cdp.send('Page.navigate', { url: `${origin}/` });
+ await sleep(6000);
+ const r = await cdp.evaluate(CHECK);
+ const out = {
+ site: origin, title: r.title, chars: r.chars, navLinks: r.navLinks, token: r.token || null, styled: r.styledBg, blocked: new Set(blocked).size,
+ content: r.chars >= 200, nav: r.navLinks >= 3, theme: r.styledBg && (Boolean(r.token) || r.ownText),
+ };
+ out.ok = out.content && out.nav && out.theme;
+ return out;
+}
+
+(async () => {
+ const sites = arg('sites') ? arg('sites').split(',').map((s) => new URL(s.trim()).origin) : await sitesFromRegistry();
+ const results = [];
+ for (const site of sites) {
+ const cdp = await launchChrome();
+ try { results.push(await checkSite(cdp, site)); }
+ catch (err) { results.push({ site, ok: false, error: err.message }); }
+ finally { cdp.close(); }
+ }
+ if (args.includes('--json')) console.log(JSON.stringify(results, null, 2));
+ else for (const r of results) console.log(`${r.ok ? 'ok ' : 'FAIL'} ${r.site} ${r.error || `content ${r.chars} chars, nav ${r.navLinks} links, theme ${r.theme ? 'yes' : 'no'}, ${r.blocked} request(s) to openvibe.network blocked`}`);
+ if (arg('out')) {
+ const at = new Date().toISOString();
+ const rows = results.map((r) => `| ${r.site} | ${r.ok ? 'pass' : 'FAIL'} | ${r.chars ?? '-'} | ${r.navLinks ?? '-'} | ${r.theme ? 'yes' : 'no'} | ${r.blocked ?? '-'} |${r.error ? ` ${r.error}` : ''}`);
+ fs.appendFileSync(arg('out'), `\n### Run ${at}\n\n| Site | Result | Text (chars) | Nav links | Theme | Blocked requests |\n|---|---|---|---|---|---|\n${rows.join('\n')}\n`);
+ }
+ process.exit(results.every((r) => r.ok) ? 0 : 1);
+})().catch((err) => { console.error(err); process.exit(2); });