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

OP OpenVibers committed 26 Sept 2026 · 749cbcfce5 · 5 files, +64 −6


Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex e11754f..9d55e35 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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.
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).
ddiff --git a/browser-harness.js b/browser-harness.js
iindex 8e99bcf..57f6bb4 100644
---- a/browser-harness.js
++++ b/browser-harness.js
@@@ -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 = tru
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 });
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 CLI
normalizeRoutes, jsonLdEntities, findText, visibleIn, growth, sitemapPaths, checkRoute, splitErrors,
WIDTHS, CHECKS, BUDGETS, AXE, PROBE,
ddiff --git a/package-lock.json b/package-lock.json
iindex 361f910..9ab0cac 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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"
ddiff --git a/package.json b/package.json
iindex 7f31261..94cf629 100644
---- a/package.json
++++ b/package.json
@@@ -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": {
ddiff --git a/test/browser-harness-chrome.test.js b/test/browser-harness-chrome.test.js
iindex 9303610..39fadb2 100644
---- a/test/browser-harness-chrome.test.js
++++ b/test/browser-harness-chrome.test.js
@@@ -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: locati
const 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: locati
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 },
});
++ // 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');