openvibers/openvibe.shared Commits
v1.21.0: browser harness checkUnreachable (ADR-024, roadmap WS-E task 1): openPage({ block }) fails every request matching a pattern, and checkUnreachable(url) reports whether a page still paints (200, settled, text, a --accent token, a background) with openvibe.network unreachable; blocked requests are not page errors. Tested in headless Chrome
Changes
@@@ -4,6 +4,15 @@ 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.21.0 — 2026-09-26++++**Browser harness: an unreachable host** (ADR-024, roadmap WS-E task 1).++- `openPage(…, { block })` makes every request matching a pattern fail, as if the host were down.++- `checkUnreachable(url, { block, widths, minText })` loads a page that way (by default with `openvibe.network` unreachable, where the theme, the frame and sign-in live). It reports whether the page still paints: status 200, settled, real text, a `--accent` theme token and a background (the body's, or the html's when the body has none). The blocked requests themselves are not counted as errors.++- Tested in headless Chrome: a page whose theme stylesheet is blocked keeps its inline default.++++Host's `browser-check.js --network-down` runs it against every site.++## 1.20.0 — 2026-09-26**Release manifest 1.2.0** (roadmap WS-P task 7; openvibe-contracts 0.60.0, ADR-016 amendment 2).@@@ -220,7 +220,7 @@ const PROBE = `(() => {})();`;/** A tab in `context`, with its errors, script requests, document status and network activity recorded. */--async function openPage(browser, context, { width = 1280, height = 900, js = true, probe = false } = {}) {++async function openPage(browser, context, { width = 1280, height = 900, js = true, probe = false, block = null } = {}) {const { targetId } = await browser.send('Target.createTarget', { url: 'about:blank', browserContextId: context.id });const { sessionId } = await browser.send('Target.attachToTarget', { targetId, flatten: true });const send = (m, p, t) => browser.send(m, p, sessionId, t);@@@ -278,6 +278,8 @@ async function openPage(browser, context, { width = 1280, height = 900, js = truconst ua = width < 700 ? browser.userAgent.replace(/\([^)]*\)/, '(Linux; Android 10; K)').replace(/ Safari\//, ' Mobile Safari/') : browser.userAgent;await send('Network.setUserAgentOverride', { userAgent: ua + USER_AGENT_SUFFIX });if (!js) await send('Emulation.setScriptExecutionDisabled', { value: true });++ // Requests matching these patterns fail as if the host were down (checkUnreachable).++ if (Array.isArray(block) && block.length) await send('Network.setBlockedURLs', { urls: block });if (probe) await send('Page.addScriptToEvaluateOnNewDocument', { source: PROBE });const evaluate = async (expression, timeoutMs) => {@@@ -707,6 +709,40 @@ async function checkOneRoute(browser, context, route, o) {return r;}++/**++ * ADR-024 (WS-E task 1): with a host unreachable (by default openvibe.network, where the theme, the frame++ * and sign-in live), does a page still paint, with the default theme? Loads `url` once per width with the++ * patterns in `block` failing, then reads the page: visible text, the --accent token, the body's++ * background, and errors other than the blocked requests themselves.++ * → { url, ok, blocked, widths: [{ width, status, settled, textChars, accent, background, errors, ok }] }++ */++async function checkUnreachable(url, { block = ['*://openvibe.network/*'], widths = [390, 1280], minText = 200, maxMs = 20000, browser: given = null, chrome = {} } = {}) {++ const browser = given || await launch(chrome);++ const out = { url, blocked: block, widths: [] };++ try {++ for (const width of widths) {++ const context = await browser.newContext();++ const page = await openPage(browser, context, { width, block });++ try {++ const settled = await page.goto(url, { maxMs });++ const s = await page.evaluate(`(() => {++ const cs = getComputedStyle(document.documentElement);++ const clear = (c) => !c || /^rgba\\(0, 0, 0, 0\\)$|^transparent$/.test(c);++ const body = document.body ? getComputedStyle(document.body).backgroundColor : '';++ return { text: (document.body && document.body.innerText || '').trim().length, accent: cs.getPropertyValue('--accent').trim(),++ background: clear(body) ? cs.backgroundColor : body, theme: document.documentElement.getAttribute('data-theme') || '' };++ })()`);++ const errors = page.state.errors.filter((e) => !/ERR_BLOCKED_BY_CLIENT|blocked/i.test(e.text || ''));++ const w = { width, status: page.state.doc ? page.state.doc.status : null, settled, textChars: s.text, accent: s.accent, background: s.background, theme: s.theme, errors };++ w.ok = w.status === 200 && settled && s.text >= minText && !!s.accent && !/^rgba\(0, 0, 0, 0\)$|^transparent$/.test(s.background);++ out.widths.push(w);++ } finally { await page.close(); await context.dispose(); }++ }++ } finally { if (!given) await browser.close(); }++ out.ok = out.widths.every((w) => w.ok);++ return out;++}++async function sample(page) {await page.send('HeapProfiler.collectGarbage').catch(() => {});await sleep(200);@@@ -827,7 +863,7 @@ async function run(opts = {}) {}module.exports = {-- run, launch, format, summarize, findChrome, axeSource,++ run, launch, format, summarize, findChrome, axeSource, checkUnreachable,// pure helpers, exported for tests and for the Host CLInormalizeRoutes, jsonLdEntities, findText, visibleIn, growth, sitemapPaths, checkRoute, splitErrors,WIDTHS, CHECKS, BUDGETS, AXE, PROBE,@@@ -1,12 +1,12 @@{"name": "openvibe-shared",-- "version": "1.20.0",++ "version": "1.21.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-shared",-- "version": "1.20.0",++ "version": "1.21.0","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0"@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "1.20.0",++ "version": "1.21.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","exports": {@@@ -29,6 +29,10 @@ const ROUTES = {'<link rel="canonical" href="ORIGIN/spa">'),'/private': () => page(`<main><h1>Private</h1><p>${words}</p></main>`),'/s.js': () => 'window.__s = (window.__s || 0) + 1;',++ // ADR-024 (checkUnreachable): an inline default theme, then the theme service's stylesheet (blocked in the check).++ '/themed': () => page(`<main><h1>Themed</h1><p>${words}</p></main>`, '<style>:root{--accent:#5b7cfa}html{background:#0a0f1c}</style><link rel="stylesheet" href="/theme.css">'),++ '/unstyled': () => page(`<main><h1>Unstyled</h1><p>${words}</p></main>`),++ '/theme.css': () => ':root{--accent:#ff0000}',};// axe stand-in: one critical violation on /b, none elsewhere.const AXE_STUB = `window.axe = { run: () => Promise.resolve({ violations: location.pathname === '/b'@@@ -40,7 +44,7 @@ const AXE_STUB = `window.axe = { run: () => Promise.resolve({ violations: locaticonst origin = `http://127.0.0.1:${server.address().port}`;const route = ROUTES[u.pathname];if (!route) { res.writeHead(404, { 'content-type': 'text/html' }); res.end(page(`<h1>Not found</h1><p>${words}</p>`)); return; }-- res.writeHead(200, { 'content-type': u.pathname.endsWith('.js') ? 'application/javascript' : 'text/html; charset=utf-8', ...(u.pathname === '/private' ? { 'x-robots-tag': 'noindex, nofollow' } : {}) });++ res.writeHead(200, { 'content-type': u.pathname.endsWith('.js') ? 'application/javascript' : u.pathname.endsWith('.css') ? 'text/css' : 'text/html; charset=utf-8', ...(u.pathname === '/private' ? { 'x-robots-tag': 'noindex, nofollow' } : {}) });res.end(route().replace(/ORIGIN/g, origin));});await new Promise((ok) => server.listen(0, '127.0.0.1', ok));@@@ -50,6 +54,15 @@ const AXE_STUB = `window.axe = { run: () => Promise.resolve({ violations: locatibase, routes: ['/', '/b', { path: '/missing', status: 404 }, '/private'], widths: [390, 1280], settleMs: 300, idleMs: 1000,navigation: { from: '/spa', to: '/spa?b', laps: 4 }, axe: { source: AXE_STUB },});++ // ADR-024 (WS-E task 1): with the theme host unreachable the page still paints, with the default theme.++ const down = await h.checkUnreachable(`${base}/themed`, { block: ['*/theme.css*'], widths: [390], minText: 50, chrome: { tmpDir } });++ assert.strictEqual(down.ok, true, JSON.stringify(down));++ assert.strictEqual(down.widths[0].accent, '#5b7cfa', 'the blocked theme did not apply: the inline default stands');++ assert.deepStrictEqual(down.widths[0].errors, [], 'the blocked request is not counted as a page error');++ const reached = await h.checkUnreachable(`${base}/themed`, { block: ['*/nothing-matches*'], widths: [390], minText: 50, chrome: { tmpDir } });++ assert.strictEqual(reached.widths[0].accent, '#ff0000', 'without the block the theme applies (so the block is what made the difference)');++ const bare = await h.checkUnreachable(`${base}/unstyled`, { block: ['*/theme.css*'], widths: [390], minText: 50, chrome: { tmpDir } });++ assert.strictEqual(bare.ok, false, 'no theme token at all fails');server.close();// Chrome is gone and so is its profile (a leftover per run fills a small disk).assert.deepStrictEqual(require('fs').readdirSync(tmpDir), [], 'the Chrome profile is removed when run() returns');
@@ -4,6 +4,15 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -4,6 +4,15 @@ 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.21.0 — 2026-09-26
+
+**Browser harness: an unreachable host** (ADR-024, roadmap WS-E task 1).
+- `openPage(…, { block })` makes every request matching a pattern fail, as if the host were down.
+- `checkUnreachable(url, { block, widths, minText })` loads a page that way (by default with `openvibe.network` unreachable, where the theme, the frame and sign-in live). It reports whether the page still paints: status 200, settled, real text, a `--accent` theme token and a background (the body's, or the html's when the body has none). The blocked requests themselves are not counted as errors.
+- Tested in headless Chrome: a page whose theme stylesheet is blocked keeps its inline default.
+
+Host's `browser-check.js --network-down` runs it against every site.
+
## 1.20.0 — 2026-09-26
## 1.20.0 — 2026-09-26
**Release manifest 1.2.0** (roadmap WS-P task 7; openvibe-contracts 0.60.0, ADR-016 amendment 2).
**Release manifest 1.2.0** (roadmap WS-P task 7; openvibe-contracts 0.60.0, ADR-016 amendment 2).
@@ -220,7 +220,7 @@ const PROBE = `(() => {
@@ -220,7 +220,7 @@ const PROBE = `(() => {
})();`;
})();`;
/** A tab in `context`, with its errors, script requests, document status and network activity recorded. */
/** A tab in `context`, with its errors, script requests, document status and network activity recorded. */
-async function openPage(browser, context, { width = 1280, height = 900, js = true, probe = false } = {}) {
+async function openPage(browser, context, { width = 1280, height = 900, js = true, probe = false, block = null } = {}) {
const { targetId } = await browser.send('Target.createTarget', { url: 'about:blank', browserContextId: context.id });
const { targetId } = await browser.send('Target.createTarget', { url: 'about:blank', browserContextId: context.id });
const { sessionId } = await browser.send('Target.attachToTarget', { targetId, flatten: true });
const { sessionId } = await browser.send('Target.attachToTarget', { targetId, flatten: true });
const send = (m, p, t) => browser.send(m, p, sessionId, t);
const send = (m, p, t) => browser.send(m, p, sessionId, t);
@@ -278,6 +278,8 @@ async function openPage(browser, context, { width = 1280, height = 900, js = tru
@@ -278,6 +278,8 @@ async function openPage(browser, context, { width = 1280, height = 900, js = tru
const ua = width < 700 ? browser.userAgent.replace(/\([^)]*\)/, '(Linux; Android 10; K)').replace(/ Safari\//, ' Mobile Safari/') : browser.userAgent;
const ua = width < 700 ? browser.userAgent.replace(/\([^)]*\)/, '(Linux; Android 10; K)').replace(/ Safari\//, ' Mobile Safari/') : browser.userAgent;
await send('Network.setUserAgentOverride', { userAgent: ua + USER_AGENT_SUFFIX });
await send('Network.setUserAgentOverride', { userAgent: ua + USER_AGENT_SUFFIX });
if (!js) await send('Emulation.setScriptExecutionDisabled', { value: true });
if (!js) await send('Emulation.setScriptExecutionDisabled', { value: true });
+ // Requests matching these patterns fail as if the host were down (checkUnreachable).
+ if (Array.isArray(block) && block.length) await send('Network.setBlockedURLs', { urls: block });
if (probe) await send('Page.addScriptToEvaluateOnNewDocument', { source: PROBE });
if (probe) await send('Page.addScriptToEvaluateOnNewDocument', { source: PROBE });
const evaluate = async (expression, timeoutMs) => {
const evaluate = async (expression, timeoutMs) => {
@@ -707,6 +709,40 @@ async function checkOneRoute(browser, context, route, o) {
@@ -707,6 +709,40 @@ async function checkOneRoute(browser, context, route, o) {
return r;
return r;
}
}
+/**
+ * ADR-024 (WS-E task 1): with a host unreachable (by default openvibe.network, where the theme, the frame
+ * and sign-in live), does a page still paint, with the default theme? Loads `url` once per width with the
+ * patterns in `block` failing, then reads the page: visible text, the --accent token, the body's
+ * background, and errors other than the blocked requests themselves.
+ * → { url, ok, blocked, widths: [{ width, status, settled, textChars, accent, background, errors, ok }] }
+ */
+async function checkUnreachable(url, { block = ['*://openvibe.network/*'], widths = [390, 1280], minText = 200, maxMs = 20000, browser: given = null, chrome = {} } = {}) {
+ const browser = given || await launch(chrome);
+ const out = { url, blocked: block, widths: [] };
+ try {
+ for (const width of widths) {
+ const context = await browser.newContext();
+ const page = await openPage(browser, context, { width, block });
+ try {
+ const settled = await page.goto(url, { maxMs });
+ const s = await page.evaluate(`(() => {
+ const cs = getComputedStyle(document.documentElement);
+ const clear = (c) => !c || /^rgba\\(0, 0, 0, 0\\)$|^transparent$/.test(c);
+ const body = document.body ? getComputedStyle(document.body).backgroundColor : '';
+ return { text: (document.body && document.body.innerText || '').trim().length, accent: cs.getPropertyValue('--accent').trim(),
+ background: clear(body) ? cs.backgroundColor : body, theme: document.documentElement.getAttribute('data-theme') || '' };
+ })()`);
+ const errors = page.state.errors.filter((e) => !/ERR_BLOCKED_BY_CLIENT|blocked/i.test(e.text || ''));
+ const w = { width, status: page.state.doc ? page.state.doc.status : null, settled, textChars: s.text, accent: s.accent, background: s.background, theme: s.theme, errors };
+ w.ok = w.status === 200 && settled && s.text >= minText && !!s.accent && !/^rgba\(0, 0, 0, 0\)$|^transparent$/.test(s.background);
+ out.widths.push(w);
+ } finally { await page.close(); await context.dispose(); }
+ }
+ } finally { if (!given) await browser.close(); }
+ out.ok = out.widths.every((w) => w.ok);
+ return out;
+}
+
async function sample(page) {
async function sample(page) {
await page.send('HeapProfiler.collectGarbage').catch(() => {});
await page.send('HeapProfiler.collectGarbage').catch(() => {});
await sleep(200);
await sleep(200);
@@ -827,7 +863,7 @@ async function run(opts = {}) {
@@ -827,7 +863,7 @@ async function run(opts = {}) {
}
}
module.exports = {
module.exports = {
- run, launch, format, summarize, findChrome, axeSource,
+ run, launch, format, summarize, findChrome, axeSource, checkUnreachable,
// pure helpers, exported for tests and for the Host CLI
// pure helpers, exported for tests and for the Host CLI
normalizeRoutes, jsonLdEntities, findText, visibleIn, growth, sitemapPaths, checkRoute, splitErrors,
normalizeRoutes, jsonLdEntities, findText, visibleIn, growth, sitemapPaths, checkRoute, splitErrors,
WIDTHS, CHECKS, BUDGETS, AXE, PROBE,
WIDTHS, CHECKS, BUDGETS, AXE, PROBE,
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "1.20.0",
+ "version": "1.21.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "1.20.0",
+ "version": "1.21.0",
"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.20.0",
+ "version": "1.21.0",
"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": {
@@ -29,6 +29,10 @@ const ROUTES = {
@@ -29,6 +29,10 @@ const ROUTES = {
'<link rel="canonical" href="ORIGIN/spa">'),
'<link rel="canonical" href="ORIGIN/spa">'),
'/private': () => page(`<main><h1>Private</h1><p>${words}</p></main>`),
'/private': () => page(`<main><h1>Private</h1><p>${words}</p></main>`),
'/s.js': () => 'window.__s = (window.__s || 0) + 1;',
'/s.js': () => 'window.__s = (window.__s || 0) + 1;',
+ // ADR-024 (checkUnreachable): an inline default theme, then the theme service's stylesheet (blocked in the check).
+ '/themed': () => page(`<main><h1>Themed</h1><p>${words}</p></main>`, '<style>:root{--accent:#5b7cfa}html{background:#0a0f1c}</style><link rel="stylesheet" href="/theme.css">'),
+ '/unstyled': () => page(`<main><h1>Unstyled</h1><p>${words}</p></main>`),
+ '/theme.css': () => ':root{--accent:#ff0000}',
};
};
// axe stand-in: one critical violation on /b, none elsewhere.
// axe stand-in: one critical violation on /b, none elsewhere.
const AXE_STUB = `window.axe = { run: () => Promise.resolve({ violations: location.pathname === '/b'
const AXE_STUB = `window.axe = { run: () => Promise.resolve({ violations: location.pathname === '/b'
@@ -40,7 +44,7 @@ const AXE_STUB = `window.axe = { run: () => Promise.resolve({ violations: locati
@@ -40,7 +44,7 @@ const AXE_STUB = `window.axe = { run: () => Promise.resolve({ violations: locati
const origin = `http://127.0.0.1:${server.address().port}`;
const origin = `http://127.0.0.1:${server.address().port}`;
const route = ROUTES[u.pathname];
const route = ROUTES[u.pathname];
if (!route) { res.writeHead(404, { 'content-type': 'text/html' }); res.end(page(`<h1>Not found</h1><p>${words}</p>`)); return; }
if (!route) { res.writeHead(404, { 'content-type': 'text/html' }); res.end(page(`<h1>Not found</h1><p>${words}</p>`)); return; }
- res.writeHead(200, { 'content-type': u.pathname.endsWith('.js') ? 'application/javascript' : 'text/html; charset=utf-8', ...(u.pathname === '/private' ? { 'x-robots-tag': 'noindex, nofollow' } : {}) });
+ res.writeHead(200, { 'content-type': u.pathname.endsWith('.js') ? 'application/javascript' : u.pathname.endsWith('.css') ? 'text/css' : 'text/html; charset=utf-8', ...(u.pathname === '/private' ? { 'x-robots-tag': 'noindex, nofollow' } : {}) });
res.end(route().replace(/ORIGIN/g, origin));
res.end(route().replace(/ORIGIN/g, origin));
});
});
await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
@@ -50,6 +54,15 @@ const AXE_STUB = `window.axe = { run: () => Promise.resolve({ violations: locati
@@ -50,6 +54,15 @@ const AXE_STUB = `window.axe = { run: () => Promise.resolve({ violations: locati
base, routes: ['/', '/b', { path: '/missing', status: 404 }, '/private'], widths: [390, 1280], settleMs: 300, idleMs: 1000,
base, routes: ['/', '/b', { path: '/missing', status: 404 }, '/private'], widths: [390, 1280], settleMs: 300, idleMs: 1000,
navigation: { from: '/spa', to: '/spa?b', laps: 4 }, axe: { source: AXE_STUB },
navigation: { from: '/spa', to: '/spa?b', laps: 4 }, axe: { source: AXE_STUB },
});
});
+ // ADR-024 (WS-E task 1): with the theme host unreachable the page still paints, with the default theme.
+ const down = await h.checkUnreachable(`${base}/themed`, { block: ['*/theme.css*'], widths: [390], minText: 50, chrome: { tmpDir } });
+ assert.strictEqual(down.ok, true, JSON.stringify(down));
+ assert.strictEqual(down.widths[0].accent, '#5b7cfa', 'the blocked theme did not apply: the inline default stands');
+ assert.deepStrictEqual(down.widths[0].errors, [], 'the blocked request is not counted as a page error');
+ const reached = await h.checkUnreachable(`${base}/themed`, { block: ['*/nothing-matches*'], widths: [390], minText: 50, chrome: { tmpDir } });
+ assert.strictEqual(reached.widths[0].accent, '#ff0000', 'without the block the theme applies (so the block is what made the difference)');
+ const bare = await h.checkUnreachable(`${base}/unstyled`, { block: ['*/theme.css*'], widths: [390], minText: 50, chrome: { tmpDir } });
+ assert.strictEqual(bare.ok, false, 'no theme token at all fails');
server.close();
server.close();
// Chrome is gone and so is its profile (a leftover per run fills a small disk).
// Chrome is gone and so is its profile (a leftover per run fills a small disk).
assert.deepStrictEqual(require('fs').readdirSync(tmpDir), [], 'the Chrome profile is removed when run() returns');
assert.deepStrictEqual(require('fs').readdirSync(tmpDir), [], 'the Chrome profile is removed when run() returns');